From b02d7910f007d30a705f64ecbe6ef45a2b301624 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 15:25:27 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Publish=20Git's=20contextual=20?= =?UTF-8?q?Apis=20from=20@executablemd/git/api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contextual Api is a seam: a consumer reaches one to call an operation and replaces one to answer it. Mixed in among records, errors, parsers and component definitions on the package root, those values were indistinguishable from data, and which names were replaceable was something a reader had to already know. `/api` is now that route, and the only one. Eight Apis — Git, RepositoryComposition, RepositoryContext, GitComposition, PullRequestAPI, IssueApi, IssueTrackerContext and GitHost — publish there with their named interfaces, the identity each was minted under, the base refusal each falls back to, the direct operations and accessors consumers call, and every type those interfaces are spelled in. An interface you cannot spell is one you cannot implement, so the types travel with them; they stay on the root too, because a record is still a record when you only want to read one. `IssueTrackerContextApi` is named rather than written inline at its createApi() call. A consumer replacing that context has to spell what it answers with, and the anonymous shape left that as something to copy out of the implementation. The Apis are gone from the root rather than published twice. A seam reachable two ways is two contracts, and the second is whichever the author happened to import. Staying put: the Plugin and its profile predicate, component registrations and definitions, the workflow installation, durable effect identifiers, errors, and record parsers. Two regressions hold it. The portable one imports through the export map under Deno, Node and Bun — the runtimes disagree about which manifest they read, so only running all three tells a missing `deno.json` subpath from a missing `package.json` one — and type-imports the whole contract, since an interface has no runtime presence to check. The source-boundary one judges the two routes by different rules, because they are not the same offence. Importing a record from the root is ordinary, so the root is judged only against the names it no longer publishes. A source module is nobody's route: reaching `../src/issue/api.ts` bypasses the export map entirely, so every name `/api` publishes is a violation there — additive contract types included. Both sets are computed from the two entrypoints rather than listed, so a name added to `/api` is covered the day it is added. That second rule is what the first pass missed: the root could publish no Api while a consumer still reached `GitHostProvider` or `IssueInput` by relative path, which resolves, typechecks, and quietly makes the subpath optional. Twelve such imports existed across nine suites. The root gains @executablemd/git as a development dependency so Workflow's tests resolve the package under Node and Bun. Workflow gains no production dependency and still imports no Git feature. --- architecture.md | 17 ++ package.json | 1 + packages/cli/src/workflow-bundle.ts | 4 +- packages/cli/src/workflow-definition.ts | 2 +- packages/cli/tests/testing-activation.test.ts | 2 +- .../cli/tests/workflow-installation.test.ts | 2 +- .../tests/workflow-lifecycle-control.test.ts | 2 +- .../cli/tests/workflow-suspension.test.ts | 2 +- packages/git/api.ts | 141 ++++++++++ packages/git/deno.json | 5 +- packages/git/mod.ts | 50 +--- packages/git/package.json | 5 +- packages/git/src/issue/context.ts | 18 +- .../git/tests/ambient-authentication.test.ts | 2 +- packages/git/tests/api-entrypoint.test.ts | 262 ++++++++++++++++++ packages/git/tests/git-add-durability.test.ts | 6 +- packages/git/tests/git-add.test.ts | 6 +- .../git/tests/git-commit-durability.test.ts | 6 +- packages/git/tests/git-commit.test.ts | 12 +- packages/git/tests/git-host-effect.test.ts | 22 +- .../git/tests/git-push-durability.test.ts | 6 +- packages/git/tests/git-push.test.ts | 8 +- .../git/tests/git-switch-durability.test.ts | 6 +- packages/git/tests/git-switch.test.ts | 10 +- packages/git/tests/git.test.ts | 2 +- packages/git/tests/github-activation.test.ts | 4 +- packages/git/tests/github-issues.test.ts | 2 +- packages/git/tests/issue-records.test.ts | 2 +- packages/git/tests/module-partition.test.ts | 138 ++++++++- packages/git/tests/plugin.test.ts | 13 +- packages/git/tests/public-entrypoint.test.ts | 101 +++++++ .../git/tests/pull-request-durability.test.ts | 4 +- packages/git/tests/pull-request-read.test.ts | 4 +- packages/git/tests/pull-request.test.ts | 2 +- .../git/tests/run-composition-ambient.test.ts | 2 +- .../git/tests/run-composition-lazy.test.ts | 2 +- .../git/tests/run-composition-remote.test.ts | 6 +- packages/workflow/tests/retained-run.test.ts | 2 +- .../workflow-lifecycle-inspection.test.ts | 2 +- packages/workflow/tests/workflow-run.test.ts | 2 +- .../workspace-effect-loaded-copy.test.ts | 25 +- pnpm-lock.yaml | 3 + 42 files changed, 771 insertions(+), 142 deletions(-) create mode 100644 packages/git/api.ts create mode 100644 packages/git/tests/api-entrypoint.test.ts diff --git a/architecture.md b/architecture.md index 928ec5684..8052a89f5 100644 --- a/architecture.md +++ b/architecture.md @@ -266,6 +266,23 @@ start` and `xmd workflow resume` are the CLI lifecycle, and they resume through the run storage below. Ordinary `xmd run` carries the bundled Git Plugin, so the repository vocabulary is available there without an operator naming it. +Git publishes on three subpaths, and which one a consumer imports from says +what kind of thing it is reaching for. `@executablemd/git/api` is the only +route to the contextual Apis — `Git`, `RepositoryComposition`, +`RepositoryContext`, `GitComposition`, `PullRequestAPI`, `IssueApi`, +`IssueTrackerContext` and `GitHost` — together with their named interfaces, +the identity each was minted under, the base refusal each falls back to, the +direct operations and accessors consumers call, and the types those interfaces +are written in. Those are the seams: a consumer reaches one to call an +operation and replaces one to answer it. The root publishes the Plugin and its +profile predicate, the component registrations and definitions, the workflow +installation, the durable effect identifiers, the errors and the record +parsers — what was said, rather than who answers. `@executablemd/git/deno` +publishes the host adapters that implement the seams, and +`@executablemd/git/credential-helper` is the standalone program Git spawns as +itself. An Api is published from exactly one of these, not re-exported from +the others, because a seam reachable two ways is two contracts. + ## Workflow run storage A workflow run recorded only in the journal of the document execution that diff --git a/package.json b/package.json index e07a97146..7941dd3d4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@executablemd/code-review-agent": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/git": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/test-support": "workspace:*", diff --git a/packages/cli/src/workflow-bundle.ts b/packages/cli/src/workflow-bundle.ts index 28b504090..85d50f9f4 100644 --- a/packages/cli/src/workflow-bundle.ts +++ b/packages/cli/src/workflow-bundle.ts @@ -46,9 +46,9 @@ import { } from "@executablemd/core"; import type { WorkflowBundleComponent } from "@executablemd/core/host"; import { decodeSourceText, sourceContentHash } from "@executablemd/workflow"; -import { readGitObject, revParse } from "@executablemd/git"; +import { readGitObject, revParse } from "@executablemd/git/api"; import type { WorkflowComponentEntry } from "@executablemd/workflow"; -import type { GitObjectFormat } from "@executablemd/git"; +import type { GitObjectFormat } from "@executablemd/git/api"; import type { EstablishedComponent } from "./workflow-definition.ts"; /** Hexadecimal digits per object id, by the format that names them. */ diff --git a/packages/cli/src/workflow-definition.ts b/packages/cli/src/workflow-definition.ts index 4f8194327..cc19e1017 100644 --- a/packages/cli/src/workflow-definition.ts +++ b/packages/cli/src/workflow-definition.ts @@ -49,7 +49,7 @@ import { sourceBundleHash, sourceContentHash, } from "@executablemd/workflow"; -import { gitObjectFormat, readGitObject, repositoryRoot, revParse } from "@executablemd/git"; +import { gitObjectFormat, readGitObject, repositoryRoot, revParse } from "@executablemd/git/api"; import type { GitWorkflowDefinitionV1, SourceBundleEntryV2, diff --git a/packages/cli/tests/testing-activation.test.ts b/packages/cli/tests/testing-activation.test.ts index 84063db49..e57f04b0d 100644 --- a/packages/cli/tests/testing-activation.test.ts +++ b/packages/cli/tests/testing-activation.test.ts @@ -20,7 +20,7 @@ import { inlineSource, registerComponents, useTempFileCompiler } from "@executab import type { Json } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { retainedWorkflowInstallation } from "@executablemd/workflow"; -import { Git } from "@executablemd/git"; +import { Git } from "@executablemd/git/api"; import { installTestingComponents } from "@executablemd/testing"; import type { TestResult } from "@executablemd/testing"; diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts index 99bdb346f..d1a2d6f80 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -28,7 +28,7 @@ import { } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; import { WorkflowLifecycle, WorkflowRunStorage } from "@executablemd/workflow"; -import { Git } from "@executablemd/git"; +import { Git } from "@executablemd/git/api"; import type { WorkflowRunDatabase, WorkflowRunStatus } from "@executablemd/workflow"; import { runWorkflow } from "../src/workflow.ts"; import type { WorkflowExecution, WorkflowHost, WorkflowRequest } from "../src/workflow.ts"; diff --git a/packages/cli/tests/workflow-lifecycle-control.test.ts b/packages/cli/tests/workflow-lifecycle-control.test.ts index 2164be52b..c0fe9bce3 100644 --- a/packages/cli/tests/workflow-lifecycle-control.test.ts +++ b/packages/cli/tests/workflow-lifecycle-control.test.ts @@ -26,7 +26,7 @@ import { } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; import { suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; -import { Git } from "@executablemd/git"; +import { Git } from "@executablemd/git/api"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { collect, inlineSource, registerComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; diff --git a/packages/cli/tests/workflow-suspension.test.ts b/packages/cli/tests/workflow-suspension.test.ts index 180e8ccde..423027a77 100644 --- a/packages/cli/tests/workflow-suspension.test.ts +++ b/packages/cli/tests/workflow-suspension.test.ts @@ -51,7 +51,7 @@ import { } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; import { SUSPENSION_REQUEST, suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; -import { Git } from "@executablemd/git"; +import { Git } from "@executablemd/git/api"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { workflowRunPath } from "@executablemd/workflow/deno"; import { withWorkflowWorkspace, WORKSPACE_FILE } from "@executablemd/workflow/deno"; diff --git a/packages/git/api.ts b/packages/git/api.ts new file mode 100644 index 000000000..c510b1d74 --- /dev/null +++ b/packages/git/api.ts @@ -0,0 +1,141 @@ +/** + * @module + * + * Every contextual Api this package owns, and the contract each is written in. + * + * A contextual Api is a seam: a consumer reaches one to *call* an operation, + * and replaces one — `Git.around({…}, { at: "min" })` — to *answer* it. Both + * sides need the same value, so both sides need one place to import it from. + * Mixed in among records, errors, parsers and component definitions on the + * package root, that value was indistinguishable from data, and which names + * were replaceable seams was something a reader had to already know. + * + * So this is the consumer route, and the only one. Eight Apis live here with + * their named interfaces, the identity each was minted under, the base refusal + * each falls back to when nobody answered, the direct operations and accessors + * consumers actually call, and every type those interfaces are spelled in. A + * name here is something you can implement. + * + * ```ts + * import { Git, RepositoryComposition } from "@executablemd/git/api"; + * + * // Call one. + * const repository = yield* RepositoryComposition.operations.ambientRepository(); + * + * // Or answer it. Providers install at `min` so a nested replacement wins + * // rather than being shadowed by an outer handler. + * yield* Git.around({ *revParse(revision) { … } }, { at: "min" }); + * ``` + * + * The types travel with the Apis because an interface you cannot spell is one + * you cannot implement. They stay exported from the package root as well: a + * record that appears in an Api signature is still an ordinary record when a + * consumer only wants to read one, and that is an additive re-export rather + * than a second home. + * + * What is *not* here: the Plugin value and its profile predicate, the + * component registrations and definitions, the workflow installation, the + * durable effect identifiers, the error classes and the record parsers. Those + * describe what was said, not who answers. The runtime providers that + * implement these seams are not here either — they live behind + * `@executablemd/git/deno`, because which host can answer is a different + * question from what the question is. + */ + +// The Git capability, and the four operations pre-bound for callers who want +// the operation rather than the seam. +export { Git, gitObjectFormat, readGitObject, repositoryRoot, revParse } from "./src/git.ts"; +export type { GitApi, GitObjectFormat } from "./src/git.ts"; + +// Repository composition: what a `` or `` selects, and +// the credential-free selection it hands back. +export { RepositoryComposition } from "./src/composition/api.ts"; +export type { + RepositoryCompositionApi, + RepositoryRequest, + WorktreeRequest, +} from "./src/composition/api.ts"; +export type { RepositorySelection } from "./src/composition/selection.ts"; + +// Which repository is lexically in scope, and the accessor that reads it. +export { currentRepository, RepositoryContext } from "./src/composition/context.ts"; +export type { RepositoryContextApi } from "./src/composition/context.ts"; + +// The authored local Git transitions — switch, add, commit, push — with the +// places they are invoked at and the results they record. +export { GitComposition } from "./src/composition/git-api.ts"; +export type { + GitAddInvocation, + GitCommitInvocation, + GitCompositionApi, + GitInvocationPlace, + GitPushInvocation, + GitSwitchInvocation, +} from "./src/composition/git-api.ts"; +export type { + GitAddResult, + GitCommitMessageSource, + GitCommitResult, + GitSwitchResult, +} from "./src/composition/git-records.ts"; +export type { GitPushOutcome } from "./src/composition/git-push-records.ts"; + +// Pull requests, with the identity the Api was minted under and the refusal a +// request nobody answered reaches. +export { + NoPullRequestProvider, + PULL_REQUEST_API, + PullRequestAPI, +} from "./src/composition/pull-request-api.ts"; +export type { + PullRequestApi, + PullRequestInput, + PullRequestOperation, + PullRequestReadOptions, + PullRequestUpsertOptions, +} from "./src/composition/pull-request-api.ts"; +export type { + PullRequestReadKind, + PullRequestReadResult, +} from "./src/composition/pull-request-read-records.ts"; +export type { PullRequestResult } from "./src/composition/pull-request-records.ts"; + +// Issues, on the same terms: middleware matches its own targets, and a request +// everyone delegated reaches `NoIssueProvider` unchanged. +export { ISSUE_API, IssueApi, NoIssueProvider } from "./src/issue/api.ts"; +export type { + IssueDetails, + IssueInput, + IssueOperation, + IssueReadOptions, + IssueReference, + IssueUpsertOptions, +} from "./src/issue/api.ts"; + +// The nearest lexical ``, the accessor that reads it, and the +// tracker value itself. +export { + currentIssueTracker, + ISSUE_TRACKER_CONTEXT, + IssueTrackerContext, +} from "./src/issue/context.ts"; +export type { IssueTrackerContextApi } from "./src/issue/context.ts"; +export type { IssueTracker } from "./src/issue/tracker.ts"; + +// The Git host the pull-request and issue providers reconcile through, with +// the durable reconciliation a provider takes part in. +export { GIT_HOST_API, GitHost } from "./src/git-host/api.ts"; +export type { + GitHostApi, + GitHostCall, + GitHostPhase, + GitHostPhaseDetails, + GitHostProvider, + GitHostRoutingRequest, +} from "./src/git-host/api.ts"; +export { reconcileGitHostEffect, withGitHostProvider } from "./src/git-host/effect.ts"; +export type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostObservation, +} from "./src/git-host/records.ts"; diff --git a/packages/git/deno.json b/packages/git/deno.json index b6178b605..d25e314ff 100644 --- a/packages/git/deno.json +++ b/packages/git/deno.json @@ -4,7 +4,8 @@ "license": "MIT", "exports": { ".": "./mod.ts", - "./deno": "./deno.ts", - "./credential-helper": "./credential-helper.ts" + "./api": "./api.ts", + "./credential-helper": "./credential-helper.ts", + "./deno": "./deno.ts" } } diff --git a/packages/git/mod.ts b/packages/git/mod.ts index 4d8882bd0..debf7f90d 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -8,6 +8,17 @@ * default export is the Plugin itself; everything beside it is the surface a * provider adapter composes against. * + * **The contextual Apis are not here.** `Git`, `RepositoryComposition`, + * `RepositoryContext`, `GitComposition`, `PullRequestAPI`, `IssueApi`, + * `IssueTrackerContext` and `GitHost` — with their interfaces, identities, + * direct operations and the types those interfaces are written in — publish + * from `@executablemd/git/api`. A seam a consumer can answer is a different + * kind of thing from a record it can read, and one route to each is what + * keeps that legible. What stays here is the Plugin and its profile + * predicate, the component registrations and definitions, the workflow + * installation, the durable effect identifiers, the errors, and the record + * parsers and their types. + * * ```md * * @@ -79,21 +90,8 @@ export { gitDirectoryEntry } from "./src/composition/definitions.ts"; */ export { declaresFor as gitPluginDeclaresFor } from "./src/plugin.ts"; export { workflowInstallation } from "./src/installation.ts"; -export { - Git, - gitObjectFormat, - GitObjectError, - GitRepositoryError, - GitRevisionError, - readGitObject, - repositoryRoot, - revParse, -} from "./src/git.ts"; -export type { GitApi, GitObjectFormat } from "./src/git.ts"; -export { RepositoryComposition } from "./src/composition/api.ts"; -export type { RepositoryCompositionApi } from "./src/composition/api.ts"; -export { currentRepository, RepositoryContext } from "./src/composition/context.ts"; -export type { RepositoryContextApi } from "./src/composition/context.ts"; +export { GitObjectError, GitRepositoryError, GitRevisionError } from "./src/git.ts"; +export type { GitObjectFormat } from "./src/git.ts"; export { GitCompositionProviderError, GitOperationError, @@ -125,13 +123,7 @@ export type { WorktreeCreationRequest, WorktreeRecord, } from "./src/composition/records.ts"; -export { - NoPullRequestProvider, - PULL_REQUEST_API, - PullRequestAPI, -} from "./src/composition/pull-request-api.ts"; export type { - PullRequestApi, PullRequestInput, PullRequestReadOptions, PullRequestUpsertOptions, @@ -141,8 +133,6 @@ export { pullRequestProviderName, } from "./src/composition/pull-request-target.ts"; export type { PullRequestTarget } from "./src/composition/pull-request-target.ts"; -export { GitComposition } from "./src/composition/git-api.ts"; -export type { GitCompositionApi } from "./src/composition/git-api.ts"; export { gitAddResultJson, gitCommitResultJson, @@ -241,7 +231,6 @@ export { compositionDocumentation, useCompositionComponents, } from "./src/composition/installation.ts"; -export { ISSUE_API, IssueApi, NoIssueProvider } from "./src/issue/api.ts"; export type { IssueDetails, IssueInput, @@ -250,11 +239,6 @@ export type { IssueReference, IssueUpsertOptions, } from "./src/issue/api.ts"; -export { - ISSUE_TRACKER_CONTEXT, - IssueTrackerContext, - currentIssueTracker, -} from "./src/issue/context.ts"; export { ISSUE_EFFECT } from "./src/issue/effect-type.ts"; export { IssueAmbiguousError, @@ -272,9 +256,7 @@ export { withinIssueCeiling, } from "./src/issue/tracker.ts"; export type { IssueDestination, IssueTracker } from "./src/issue/tracker.ts"; -export { GIT_HOST_API, GitHost } from "./src/git-host/api.ts"; export type { - GitHostApi, GitHostCall, GitHostPhase, GitHostPhaseDetails, @@ -307,11 +289,7 @@ export type { GitHostObservation, GitHostReconciliationRecord, } from "./src/git-host/records.ts"; -export { - GIT_HOST_EFFECT, - reconcileGitHostEffect, - withGitHostProvider, -} from "./src/git-host/effect.ts"; +export { GIT_HOST_EFFECT } from "./src/git-host/effect.ts"; export { filteredRepositoryIdentity, parseRepositoryIdentity, diff --git a/packages/git/package.json b/packages/git/package.json index c4da9d32e..368b6d4d5 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -5,8 +5,9 @@ "type": "module", "exports": { ".": "./mod.ts", - "./deno": "./deno.ts", - "./credential-helper": "./credential-helper.ts" + "./api": "./api.ts", + "./credential-helper": "./credential-helper.ts", + "./deno": "./deno.ts" }, "dependencies": { "@effectionx/context-api": "0.6.0", diff --git a/packages/git/src/issue/context.ts b/packages/git/src/issue/context.ts index e54ec6c1b..dc069c1a8 100644 --- a/packages/git/src/issue/context.ts +++ b/packages/git/src/issue/context.ts @@ -23,9 +23,23 @@ import type { IssueTracker } from "./tracker.ts"; export const ISSUE_TRACKER_CONTEXT = "executablemd.workflow.issue-tracker"; -export const IssueTrackerContext: Api<{ readonly current: IssueTracker | undefined }> = createApi<{ +/** + * What the nearest enclosing `` states, or nothing. + * + * Named rather than written inline at the `createApi()` call, because a + * consumer replacing this context has to spell the shape it is answering with, + * and an anonymous one leaves that shape as something to copy out of the + * implementation. `undefined` is a value here — "no tracker is in scope" — not + * an absence of an answer. + */ +export interface IssueTrackerContextApi { readonly current: IssueTracker | undefined; -}>(ISSUE_TRACKER_CONTEXT, { current: undefined }); +} + +export const IssueTrackerContext: Api = createApi( + ISSUE_TRACKER_CONTEXT, + { current: undefined }, +); /** The nearest enclosing Issue tracker, or `undefined` when there is none. */ export function currentIssueTracker(): Operation { diff --git a/packages/git/tests/ambient-authentication.test.ts b/packages/git/tests/ambient-authentication.test.ts index 1829e4911..fd843b689 100644 --- a/packages/git/tests/ambient-authentication.test.ts +++ b/packages/git/tests/ambient-authentication.test.ts @@ -54,7 +54,7 @@ import type { import { gitHubSource } from "../src/deno/composition/github.ts"; import { denoGitHubAccess, denoGitHubSource } from "../src/deno/composition/github-host.ts"; import { GITHUB, useGitHubIssues } from "../src/deno/issue/github.ts"; -import { IssueApi } from "../src/issue/api.ts"; +import { IssueApi } from "@executablemd/git/api"; import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; diff --git a/packages/git/tests/api-entrypoint.test.ts b/packages/git/tests/api-entrypoint.test.ts new file mode 100644 index 000000000..d5784f77a --- /dev/null +++ b/packages/git/tests/api-entrypoint.test.ts @@ -0,0 +1,262 @@ +/** + * `@executablemd/git/api` is the one route to Git's contextual seams, on every + * runtime that can run a document. + * + * An Api is a seam: a consumer reaches one to call an operation and replaces + * one to answer it. Which means the value has to arrive — through the package + * export map, under the bare specifier a consumer writes — and it has to + * arrive from exactly one place. A seam reachable two ways is two contracts, + * and the second one is whichever the author happened to import. + * + * Portable on purpose. The Deno-only companion in `public-entrypoint.test.ts` + * reads `@executablemd/git/deno`, which reaches Workflow's storage adapter and + * so `node:sqlite`; nothing here does, so all three runtimes can ask the + * question that matters — does the export map admit this subpath — and answer + * it the way their own resolver would. A subpath missing from `deno.json` + * fails under Deno, one missing from `package.json` fails under Node and Bun, + * and only running all three tells those apart. + * + * Read through `Reflect.get` rather than a cast: what a module exported is + * whatever it exported, and the members below are proven present rather than + * claimed. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { until } from "effection"; +import type { Operation } from "effection"; + +// The complete contract, type-imported. This is not decoration: a type that +// stopped being published fails the typecheck of this file, which is the only +// way an interface — having no runtime presence — can be held to the export +// map at all. +import type { + CompleteGitHostEffectRequest, + GitAddInvocation, + GitAddResult, + GitApi, + GitCommitInvocation, + GitCommitMessageSource, + GitCommitResult, + GitCompositionApi, + GitHostApi, + GitHostCall, + GitHostCompletion, + GitHostObservation, + GitHostPhase, + GitHostPhaseDetails, + GitHostProvider, + GitHostRoutingRequest, + GitInvocationPlace, + GitObjectFormat, + GitPushInvocation, + GitPushOutcome, + GitSwitchInvocation, + GitSwitchResult, + IssueDetails, + IssueInput, + IssueOperation, + IssueReadOptions, + IssueReference, + IssueTracker, + IssueTrackerContextApi, + IssueUpsertOptions, + PullRequestApi, + PullRequestInput, + PullRequestOperation, + PullRequestReadKind, + PullRequestReadOptions, + PullRequestReadResult, + PullRequestResult, + PullRequestUpsertOptions, + RepositoryCompositionApi, + RepositoryContextApi, + RepositoryRequest, + RepositorySelection, + WorktreeRequest, +} from "@executablemd/git/api"; + +/** + * The eight contextual Apis, by the name a consumer imports. + * + * Eight rather than "some": each is a separate seam with its own provider + * story, and an Api that silently stopped being published is a consumer that + * can no longer answer it. + */ +const CONTEXTUAL_APIS: readonly string[] = [ + "Git", + "GitComposition", + "GitHost", + "IssueApi", + "IssueTrackerContext", + "PullRequestAPI", + "RepositoryComposition", + "RepositoryContext", +]; + +/** + * What travels with them: the identity each Api was minted under, the refusal + * a request nobody answered reaches, and the operations and accessors a + * consumer calls directly. + */ +const COMPANIONS: readonly string[] = [ + "GIT_HOST_API", + "ISSUE_API", + "ISSUE_TRACKER_CONTEXT", + "NoIssueProvider", + "NoPullRequestProvider", + "PULL_REQUEST_API", + "currentIssueTracker", + "currentRepository", + "gitObjectFormat", + "readGitObject", + "reconcileGitHostEffect", + "repositoryRoot", + "revParse", + "withGitHostProvider", +]; + +/** + * Whether a published value is a contextual Api. + * + * A `createApi()` value carries `around` — how a provider replaces it — and + * `operations` — how a caller reaches it. Recognized by that shape rather than + * by a list of names, because a list only contains the seams somebody + * remembered, and the point of the exclusivity check below is to catch the one + * nobody did. + */ +function isApi(value: unknown): boolean { + if (typeof value !== "object" || value === null) { + return false; + } + const around: unknown = Reflect.get(value, "around"); + const operations: unknown = Reflect.get(value, "operations"); + return typeof around === "function" && typeof operations === "object" && operations !== null; +} + +/** The names a module published, through the export map, under its bare specifier. */ +function* published(specifier: string): Operation { + const namespace: unknown = yield* until(import(specifier)); + if (typeof namespace !== "object" || namespace === null) { + return []; + } + return Object.keys(namespace); +} + +/** Every published name that is a contextual Api, sorted. */ +function* seams(specifier: string): Operation { + const namespace: unknown = yield* until(import(specifier)); + if (typeof namespace !== "object" || namespace === null) { + return []; + } + return Object.keys(namespace) + .filter((name) => isApi(Reflect.get(namespace, name))) + .sort(); +} + +describe("the @executablemd/git/api entrypoint", () => { + it("resolves through the package export map", function* () { + // The positive control for everything below: an import that failed, or a + // namespace with nothing in it, would satisfy every membership check by + // having no names to contradict them. + const names = yield* published("@executablemd/git/api"); + expect(names.length > 0).toBe(true); + }); + + it("publishes all eight contextual Apis", function* () { + const names = yield* published("@executablemd/git/api"); + expect(CONTEXTUAL_APIS.filter((name) => !names.includes(name))).toEqual([]); + // And each is genuinely a seam rather than a same-named value: a plain + // record exported under `Git` would satisfy the membership above. + const recognized = yield* seams("@executablemd/git/api"); + expect(CONTEXTUAL_APIS.filter((name) => !recognized.includes(name))).toEqual([]); + }); + + it("publishes the identities, refusals and operations that travel with them", function* () { + const names = yield* published("@executablemd/git/api"); + expect(COMPANIONS.filter((name) => !names.includes(name))).toEqual([]); + }); + + /** + * The exclusivity half: one route, not a preferred one. + * + * `@executablemd/git` publishes the Plugin, the records, the parsers and the + * errors — plenty of names — so this is not asserting that the root is + * empty. It is asserting that nothing there is a seam. + */ + it("is the only entrypoint publishing a contextual Api", function* () { + // The detector has to be working, or the absence below proves nothing. + const recognized = yield* seams("@executablemd/git/api"); + expect(recognized.length).toBe(CONTEXTUAL_APIS.length); + + const rootNames = yield* published("@executablemd/git"); + expect(rootNames.length > 0).toBe(true); + const leaked = yield* seams("@executablemd/git"); + expect(`root: ${leaked.join(",")}`).toBe("root: "); + }); + + /** + * The type contract, held to the export map. + * + * A type has no runtime presence, so the type-import above is what pins it — + * this case exists to give those imports a use, so that a removed type is a + * typecheck failure rather than an unused import somebody deletes. + */ + it("publishes the complete type contract", function* () { + const format: GitObjectFormat = "sha1"; + const phase: GitHostPhase = "observe"; + const operation: IssueOperation = "read"; + const pullRequest: PullRequestOperation = "read"; + const readKind: PullRequestReadKind = "reviews"; + + expect(`${format} ${phase} ${operation} ${pullRequest} ${readKind}`).toBe( + "sha1 observe read read reviews", + ); + + // The interfaces and records, named in positions that require them to + // exist. `undefined` is a legal value for each binding, so nothing here + // constructs a shape this file would have to keep in step with. + const contract: { + git?: GitApi; + repository?: RepositoryCompositionApi; + repositoryContext?: RepositoryContextApi; + composition?: GitCompositionApi; + pull?: PullRequestApi; + tracker?: IssueTrackerContextApi; + host?: GitHostApi; + request?: RepositoryRequest; + worktree?: WorktreeRequest; + selection?: RepositorySelection; + place?: GitInvocationPlace; + switchInvocation?: GitSwitchInvocation; + addInvocation?: GitAddInvocation; + commitInvocation?: GitCommitInvocation; + pushInvocation?: GitPushInvocation; + switchResult?: GitSwitchResult; + addResult?: GitAddResult; + commitResult?: GitCommitResult; + messageSource?: GitCommitMessageSource; + pushOutcome?: GitPushOutcome; + pullInput?: PullRequestInput; + pullRead?: PullRequestReadOptions; + pullUpsert?: PullRequestUpsertOptions; + pullReadResult?: PullRequestReadResult; + pullResult?: PullRequestResult; + issueInput?: IssueInput; + issueUpsert?: IssueUpsertOptions; + issueReference?: IssueReference; + issueDetails?: IssueDetails; + issueRead?: IssueReadOptions; + issueTracker?: IssueTracker; + routing?: GitHostRoutingRequest; + details?: GitHostPhaseDetails; + call?: GitHostCall; + provider?: GitHostProvider; + complete?: CompleteGitHostEffectRequest; + observation?: GitHostObservation; + completion?: GitHostCompletion; + } = {}; + + expect(Object.keys(contract)).toEqual([]); + }); +}); diff --git a/packages/git/tests/git-add-durability.test.ts b/packages/git/tests/git-add-durability.test.ts index 7d6d690d2..c26e5e5d6 100644 --- a/packages/git/tests/git-add-durability.test.ts +++ b/packages/git/tests/git-add-durability.test.ts @@ -25,8 +25,8 @@ import { GitOperationError, GitOperationProtocolError, } from "../src/composition/errors.ts"; -import { currentRepository } from "../src/composition/context.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { currentRepository } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; import type { RepositoryRecord } from "../src/composition/records.ts"; import { WORKSPACE_GIT_ADD } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; @@ -62,7 +62,7 @@ import { import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; const REMOTE = { commits: [ { diff --git a/packages/git/tests/git-add.test.ts b/packages/git/tests/git-add.test.ts index aed35512c..84d83e0c7 100644 --- a/packages/git/tests/git-add.test.ts +++ b/packages/git/tests/git-add.test.ts @@ -28,8 +28,8 @@ import { GitOperationAdmissionError, GitOperationError, } from "../src/composition/errors.ts"; -import { currentRepository, RepositoryContext } from "../src/composition/context.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { currentRepository, RepositoryContext } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; import { parseGitAddResult } from "../src/composition/git-records.ts"; import type { GitAddExpectation } from "../src/composition/git-records.ts"; import { useCompositionComponents } from "../src/composition/installation.ts"; @@ -44,7 +44,7 @@ import type { GitWorkspaceOptions } from "../src/deno/attachment.ts"; import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; import { causedBy, countingHost, diff --git a/packages/git/tests/git-commit-durability.test.ts b/packages/git/tests/git-commit-durability.test.ts index 7163f74c9..32f4b932f 100644 --- a/packages/git/tests/git-commit-durability.test.ts +++ b/packages/git/tests/git-commit-durability.test.ts @@ -25,8 +25,8 @@ import { GitOperationError, GitOperationProtocolError, } from "../src/composition/errors.ts"; -import { currentRepository } from "../src/composition/context.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { currentRepository } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; import type { RepositoryRecord } from "../src/composition/records.ts"; import { WORKSPACE_GIT_COMMIT } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; @@ -63,7 +63,7 @@ import { import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; const REMOTE = { commits: [{ message: "first", entries: [{ path: "which.txt", content: "main\n" }] }], } as const; diff --git a/packages/git/tests/git-commit.test.ts b/packages/git/tests/git-commit.test.ts index 9110386ff..f01eccce8 100644 --- a/packages/git/tests/git-commit.test.ts +++ b/packages/git/tests/git-commit.test.ts @@ -26,13 +26,11 @@ import { GitOperationError, GitOperationInfrastructureError, } from "../src/composition/errors.ts"; -import { currentRepository, RepositoryContext } from "../src/composition/context.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { currentRepository, RepositoryContext } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; import { parseGitCommitResult } from "../src/composition/git-records.ts"; -import type { - GitCommitExpectation, - GitCommitMessageSource, -} from "../src/composition/git-records.ts"; +import type { GitCommitExpectation } from "../src/composition/git-records.ts"; +import type { GitCommitMessageSource } from "@executablemd/git/api"; import { useCompositionComponents } from "../src/composition/installation.ts"; import { admitCommitMessage, @@ -75,7 +73,7 @@ import { import { gitPluginAdmissions } from "./support/composition.ts"; import { dropRootClose } from "./support/replay.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; /** One tracked file at the root and one in a subdirectory. */ const REMOTE = { commits: [ diff --git a/packages/git/tests/git-host-effect.test.ts b/packages/git/tests/git-host-effect.test.ts index f3ed0163c..9bbc3876a 100644 --- a/packages/git/tests/git-host-effect.test.ts +++ b/packages/git/tests/git-host-effect.test.ts @@ -38,18 +38,11 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; import { retainedWorkflowInstallation } from "../../workflow/src/run.ts"; import { gitPlugin } from "../src/plugin.ts"; import type { WorkflowRun } from "../../workflow/src/run.ts"; -import { - GIT_HOST_EFFECT, - reconcileGitHostEffect, - withGitHostProvider, -} from "../src/git-host/effect.ts"; -import { GIT_HOST_API } from "../src/git-host/api.ts"; -import type { - GitHostApi, - GitHostCall, - GitHostProvider, - GitHostRoutingRequest, -} from "../src/git-host/api.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; +import { reconcileGitHostEffect, withGitHostProvider } from "@executablemd/git/api"; +import { GIT_HOST_API } from "@executablemd/git/api"; +import type { GitHostCall, GitHostProvider, GitHostRoutingRequest } from "@executablemd/git/api"; +import type { GitHostApi } from "@executablemd/git/api"; import { GitHostAmbiguousError, GitHostConflictError, @@ -61,13 +54,12 @@ import { gitHostRequestFingerprint, parseGitHostReconciliationRecord, } from "../src/git-host/records.ts"; +import type { GitHostEffectRequest, GitHostReconciliationRecord } from "../src/git-host/records.ts"; import type { CompleteGitHostEffectRequest, GitHostCompletion, - GitHostEffectRequest, GitHostObservation, - GitHostReconciliationRecord, -} from "../src/git-host/records.ts"; +} from "@executablemd/git/api"; /** * The admissions the Git Plugin contributes, as a host installing it receives diff --git a/packages/git/tests/git-push-durability.test.ts b/packages/git/tests/git-push-durability.test.ts index e35f5f6c5..02f3a322f 100644 --- a/packages/git/tests/git-push-durability.test.ts +++ b/packages/git/tests/git-push-durability.test.ts @@ -36,7 +36,7 @@ import { type GitPushInputs, } from "../src/composition/git-push-records.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { GitComposition } from "@executablemd/git/api"; import type { RepositoryRecord } from "../src/composition/records.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; @@ -49,8 +49,8 @@ import { withStorage, } from "../../workflow/tests/support/storage.ts"; import { remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; -import { currentRepository } from "../src/composition/context.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import { currentRepository } from "@executablemd/git/api"; +import type { RepositorySelection } from "@executablemd/git/api"; import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { causedBy, diff --git a/packages/git/tests/git-push.test.ts b/packages/git/tests/git-push.test.ts index ae419e61d..e94e7eb33 100644 --- a/packages/git/tests/git-push.test.ts +++ b/packages/git/tests/git-push.test.ts @@ -20,9 +20,9 @@ import { useTempDirectory } from "@executablemd/test-support/temp"; import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; import type { ComponentRegistration } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; -import { GitHost } from "../src/git-host/api.ts"; -import type { GitHostCall } from "../src/git-host/api.ts"; -import { RepositoryContext } from "../src/composition/context.ts"; +import { GitHost } from "@executablemd/git/api"; +import type { GitHostCall } from "@executablemd/git/api"; +import { RepositoryContext } from "@executablemd/git/api"; import { GitCompositionProviderError, GitOperationAdmissionError, @@ -71,7 +71,7 @@ import { import type { CountingHost } from "./support/composition.ts"; import { committedRoot, latestRoot, publishedRoots } from "./support/replay.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; const REMOTE = { commits: [ { diff --git a/packages/git/tests/git-switch-durability.test.ts b/packages/git/tests/git-switch-durability.test.ts index 6aae9d475..9123e6771 100644 --- a/packages/git/tests/git-switch-durability.test.ts +++ b/packages/git/tests/git-switch-durability.test.ts @@ -27,9 +27,9 @@ import { GitOperationProtocolError, } from "../src/composition/errors.ts"; import { DivergenceError } from "@executablemd/durable-streams"; -import { RepositoryComposition } from "../src/composition/api.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import { RepositoryComposition } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; +import type { RepositorySelection } from "@executablemd/git/api"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; diff --git a/packages/git/tests/git-switch.test.ts b/packages/git/tests/git-switch.test.ts index 9821d30dc..fdd2d5a3e 100644 --- a/packages/git/tests/git-switch.test.ts +++ b/packages/git/tests/git-switch.test.ts @@ -30,8 +30,8 @@ import { GitOperationError, GitOperationInfrastructureError, } from "../src/composition/errors.ts"; -import { currentRepository, RepositoryContext } from "../src/composition/context.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; +import { currentRepository, RepositoryContext } from "@executablemd/git/api"; +import { GitComposition } from "@executablemd/git/api"; import { parseGitSwitchResult } from "../src/composition/git-records.ts"; import type { GitSwitchExpectation } from "../src/composition/git-records.ts"; import { useCompositionComponents } from "../src/composition/installation.ts"; @@ -72,10 +72,8 @@ import { gitPluginAdmissions } from "./support/composition.ts"; import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; -import { - filteredRepositoryIdentity, - type RepositorySelection, -} from "../src/composition/selection.ts"; +import { filteredRepositoryIdentity } from "../src/composition/selection.ts"; +import { type RepositorySelection } from "@executablemd/git/api"; /** * Two branches whose content differs, plus one file that does not. * diff --git a/packages/git/tests/git.test.ts b/packages/git/tests/git.test.ts index 966dda1de..484a8319c 100644 --- a/packages/git/tests/git.test.ts +++ b/packages/git/tests/git.test.ts @@ -13,7 +13,7 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; import type { Operation } from "effection"; import { API } from "@executablemd/runtime"; -import { Git, revParse } from "../src/git.ts"; +import { Git, revParse } from "@executablemd/git/api"; interface ExecCall { command: string[]; diff --git a/packages/git/tests/github-activation.test.ts b/packages/git/tests/github-activation.test.ts index a791f8ecc..6901bb359 100644 --- a/packages/git/tests/github-activation.test.ts +++ b/packages/git/tests/github-activation.test.ts @@ -32,11 +32,11 @@ import { collect, inlineSource } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { gitPlugin } from "../src/plugin.ts"; -import { IssueApi } from "../src/issue/api.ts"; +import { IssueApi } from "@executablemd/git/api"; import { GITHUB, useGitHubIssues } from "../src/deno/issue/github.ts"; import { useGitHubPullRequestReads } from "../src/deno/composition/pull-request-reads.ts"; import { GITHUB_PULL_REQUESTS_ENV } from "../src/deno/composition/pull-request-configuration.ts"; -import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +import { PullRequestAPI } from "@executablemd/git/api"; import type { GitHubAccess, GitHubHttpResponse, diff --git a/packages/git/tests/github-issues.test.ts b/packages/git/tests/github-issues.test.ts index 824895a9b..9d5dc5c44 100644 --- a/packages/git/tests/github-issues.test.ts +++ b/packages/git/tests/github-issues.test.ts @@ -25,7 +25,7 @@ import { withinIssueCeiling, } from "../src/issue/tracker.ts"; import { issueIdempotencyKey, normalizedTags } from "../src/issue/records.ts"; -import type { IssueInput } from "../src/issue/api.ts"; +import type { IssueInput } from "@executablemd/git/api"; import type { StoredIssue } from "./support/github.ts"; const TARGET = "https://github.com/octo/project"; diff --git a/packages/git/tests/issue-records.test.ts b/packages/git/tests/issue-records.test.ts index 89297028c..d618bbb66 100644 --- a/packages/git/tests/issue-records.test.ts +++ b/packages/git/tests/issue-records.test.ts @@ -39,7 +39,7 @@ import { type IssueRequest, type IssueUpsertRequest, } from "../src/issue/records.ts"; -import type { IssueInput } from "../src/issue/api.ts"; +import type { IssueInput } from "@executablemd/git/api"; const TARGET = "https://github.com/octo/project"; diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 9d62471a2..e39a0b4cb 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -43,6 +43,7 @@ const PACKAGE = "packages/git"; * module would join it by existing, which is the one thing this scan is for. */ const PROVIDER_NEUTRAL: readonly string[] = [ + "api.ts", "mod.ts", "src/composition/api.ts", "src/composition/components/Dir.ts", @@ -211,6 +212,66 @@ function subpaths(declared: unknown): string[] | undefined { return Object.keys(exports); } +/** + * The names an entrypoint's source exports, read as named bindings. + * + * Line-oriented rather than parsed, because what is needed here is which names + * a module lists — and an export block spanning several lines lists one name + * per line. A statement this cannot read contributes nothing, which the + * non-vacuity checks below are what guard. + */ +function exportedNames(source: string): string[] { + const found = new Set(); + for (const block of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}\s*from/g)) { + for (const entry of (block[1] ?? "").split(",")) { + const name = entry.replace("type ", "").split(" as ").pop()?.trim(); + if (name !== undefined && name !== "") { + found.add(name); + } + } + } + return [...found].sort(); +} + +/** The named bindings one import statement brings in, with where from. */ +function importedNames(source: string): { specifier: string; names: string[] }[] { + const found: { specifier: string; names: string[] }[] = []; + for (const statement of source.matchAll( + /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*"([^"]+)";/g, + )) { + const specifier = statement[2] ?? ""; + const names = (statement[1] ?? "") + .split(",") + .map((entry) => entry.replace("type ", "").split(" as ")[0]?.trim() ?? "") + .filter((name) => name !== ""); + found.push({ specifier, names }); + } + return found; +} + +/** + * Which rule a specifier is judged by, or nothing when it names neither route. + * + * The two differ, and the difference is the point. The **root** is a legitimate + * home for a record a consumer only wants to read, so importing `IssueInput` + * from `@executablemd/git` is fine and only a name the root no longer exports + * is a violation. A **source module** is nobody's route: reaching + * `../src/issue/api.ts` bypasses the export map entirely, so every name `/api` + * publishes is a violation there, additive contract types included. + */ +function route(consumer: string, specifier: string): "root" | "source" | undefined { + if (specifier === "@executablemd/git") { + return "root"; + } + if (!specifier.startsWith(".")) { + return undefined; + } + const reaches = + specifier.includes("git/src/") || + (consumer.startsWith(`${PACKAGE}/tests/`) && specifier.startsWith("../src/")); + return reaches ? "source" : undefined; +} + /** Where a module the scan finds is resolved from. */ function path(relative: string): string { return `${PACKAGE}/${relative}`; @@ -221,6 +282,7 @@ function* production(): Operation { const found = yield* glob({ root: REPOSITORY, patterns: [ + `${PACKAGE}/api.ts`, `${PACKAGE}/mod.ts`, `${PACKAGE}/deno.ts`, `${PACKAGE}/credential-helper.ts`, @@ -380,9 +442,11 @@ describe("the three halves of @executablemd/git", () => { } }); - it("admits exactly the two entrypoints the manifests declare", function* () { - // A third subpath would be a third contract. `./credential-helper` is the - // one beside them, and it is Git's own rather than GitHub's. + it("admits exactly the entrypoints the manifests declare", function* () { + // Each subpath is a separate contract, so the list is exact rather than a + // minimum: `./api` publishes the contextual Apis, `./deno` the host that + // answers them, and `./credential-helper` the standalone program Git spawns + // as itself. None of them is GitHub's. for (const manifest of ["deno.json", "package.json"]) { const declared: unknown = JSON.parse( yield* readTextFile(join(REPOSITORY, PACKAGE, manifest)), @@ -397,7 +461,7 @@ describe("the three halves of @executablemd/git", () => { // Copied before sorting: `toSorted` is ES2023 and the Node typecheck's // lib is ES2022, and `names` is read again below. expect(`${manifest}: ${[...names].sort().join(" ")}`).toBe( - `${manifest}: . ./credential-helper ./deno`, + `${manifest}: . ./api ./credential-helper ./deno`, ); // And no subpath names GitHub: the implementation ships inside this // package rather than beside it. @@ -406,4 +470,70 @@ describe("the three halves of @executablemd/git", () => { ); } }); + /** + * A public contextual Api is imported from `/api` and from nowhere else. + * + * The runtime namespace says what an entrypoint publishes; this says what + * consumers actually reach for. Those are different failures: the root could + * publish no Api while a consumer still imported one by relative path into + * `packages/git/src`, which resolves, typechecks, and quietly makes the + * subpath optional. + * + * The exclusive set is computed rather than listed — whatever `/api` exports + * and the root does not — so a name added to `/api` is covered here the day + * it is added, without a list to keep in step. + */ + it("keeps every consumer's public Api imports on the subpath", function* () { + const api = exportedNames(yield* readTextFile(join(REPOSITORY, PACKAGE, "api.ts"))); + const root = exportedNames(yield* readTextFile(join(REPOSITORY, PACKAGE, "mod.ts"))); + const exclusive = api.filter((name) => !root.includes(name)); + + // Both readings have to have worked. An unreadable `api.ts` would make the + // exclusive set empty and every consumer below innocent. + expect(api.length > 0).toBe(true); + expect(root.length > 0).toBe(true); + expect(exclusive).toContain("Git"); + expect(exclusive).toContain("GitHost"); + expect(exclusive).toContain("RepositoryComposition"); + // And the additive branch has something to catch: these are published by + // `/api` *and* by the root, so they are legal from the root and forbidden + // from a source module. A set holding only exclusive names would make the + // second rule identical to the first. + const additive = api.filter((name) => root.includes(name)); + expect(additive).toContain("GitHostProvider"); + expect(additive).toContain("IssueInput"); + + // Every module that could consume Git, except the package's own + // implementation — which is entitled to its relative imports — and the two + // entrypoints that define the split. + const candidates = yield* glob({ + root: REPOSITORY, + patterns: ["packages/*/src/**/*.ts", "packages/*/tests/**/*.ts", "scripts/**/*.ts"], + }); + const consumers = candidates + .map((entry) => entry.path) + .filter((file) => !file.startsWith(`${PACKAGE}/src/`)) + .sort(); + expect(consumers.length > 0).toBe(true); + + const violations: string[] = []; + for (const consumer of consumers) { + const source = yield* readTextFile(join(REPOSITORY, consumer)); + for (const { specifier, names } of importedNames(source)) { + const judged = route(consumer, specifier); + if (judged === undefined) { + continue; + } + // The root is judged against the names it no longer publishes; a + // source module against everything `/api` does. + const forbidden = judged === "root" ? exclusive : api; + for (const name of names) { + if (forbidden.includes(name)) { + violations.push(`${consumer} imports ${name} from ${specifier}`); + } + } + } + } + expect(violations).toEqual([]); + }); }); diff --git a/packages/git/tests/plugin.test.ts b/packages/git/tests/plugin.test.ts index 9b8179981..771f43030 100644 --- a/packages/git/tests/plugin.test.ts +++ b/packages/git/tests/plugin.test.ts @@ -26,18 +26,15 @@ import { retainedWorkflowInstallation } from "../../workflow/src/run.ts"; import type { WorkflowRun } from "../../workflow/src/run.ts"; import { declaresFor, gitPlugin } from "../src/plugin.ts"; import { retainedGitHostIdentitiesHere, retainedIssueIdentitiesHere } from "../src/identities.ts"; -import { - GIT_HOST_EFFECT, - reconcileGitHostEffect, - withGitHostProvider, -} from "../src/git-host/effect.ts"; -import type { GitHostProvider } from "../src/git-host/api.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; +import { reconcileGitHostEffect, withGitHostProvider } from "@executablemd/git/api"; +import type { GitHostProvider } from "@executablemd/git/api"; +import type { GitHostEffectRequest } from "../src/git-host/records.ts"; import type { CompleteGitHostEffectRequest, GitHostCompletion, - GitHostEffectRequest, GitHostObservation, -} from "../src/git-host/records.ts"; +} from "@executablemd/git/api"; const SOURCE = "\n"; diff --git a/packages/git/tests/public-entrypoint.test.ts b/packages/git/tests/public-entrypoint.test.ts index d9f44eeb5..d965e504b 100644 --- a/packages/git/tests/public-entrypoint.test.ts +++ b/packages/git/tests/public-entrypoint.test.ts @@ -71,6 +71,61 @@ const PUBLISHED: readonly string[] = [ "useGitHubPullRequests", ]; +/** + * Every contextual Api this package owns, with what travels beside it. + * + * Named, so that losing one is a failure rather than a silent narrowing: each + * is a seam a consumer either calls or replaces, and an Api that quietly stops + * being published is a consumer that can no longer answer. + */ +const API_PUBLISHED: readonly string[] = [ + "GIT_HOST_API", + "Git", + "GitComposition", + "GitHost", + "ISSUE_API", + "ISSUE_TRACKER_CONTEXT", + "IssueApi", + "IssueTrackerContext", + "NoIssueProvider", + "NoPullRequestProvider", + "PULL_REQUEST_API", + "PullRequestAPI", + "RepositoryComposition", + "RepositoryContext", + "currentIssueTracker", + "currentRepository", + "gitObjectFormat", + "readGitObject", + "repositoryRoot", + "revParse", +]; + +/** + * Whether a published value is a contextual Api. + * + * A `createApi()` value carries `around` — how a provider replaces it — and + * `operations` — how a caller reaches it. Together those are what makes a name + * a seam rather than data, and testing for them recognizes an Api nobody + * thought to list. + */ +function isApi(value: unknown): boolean { + if (typeof value !== "object" || value === null) { + return false; + } + // Narrowed by `in` rather than asserted: what arrives here is whatever a + // module exported, so the members have to be proven present rather than + // claimed. + if (!("around" in value) || !("operations" in value)) { + return false; + } + return ( + typeof value.around === "function" && + typeof value.operations === "object" && + value.operations !== null + ); +} + describe("what @executablemd/git publishes", () => { it("publishes the GitHub contracts a host needs", function* () { const published = yield* until(import("@executablemd/git/deno")); @@ -88,6 +143,52 @@ describe("what @executablemd/git publishes", () => { expect(ROOT_PUBLISHED.filter((name) => !names.includes(name))).toEqual([]); }); + /** + * Every contextual Api, reached the way a consumer reaches it. + * + * Through the bare `@executablemd/git/api` specifier, so this fails if the + * subpath is missing from an export map rather than only if the file is + * missing from the tree — a module that exists and is unreachable publishes + * nothing. + */ + it("publishes every contextual Api from /api", function* () { + const published = yield* until(import("@executablemd/git/api")); + const names = Object.keys(published); + expect(names.length > 0).toBe(true); + expect(API_PUBLISHED.filter((name) => !names.includes(name))).toEqual([]); + }); + + /** + * The boundary itself: an Api is reachable from `/api` and from nowhere else. + * + * Recognized by shape rather than by name, because a list of names is a list + * of the seams somebody remembered. Anything carrying both `around` and + * `operations` is a `createApi()` value — something a consumer can replace — + * and the whole point of the split is that those live in one place. A new Api + * re-exported from the root fails here without anyone updating a list. + */ + it("publishes no contextual Api from the root or the Deno entrypoint", function* () { + // The positive control for the detector: if this recognized nothing, every + // absence below would be vacuous. + const api = yield* until(import("@executablemd/git/api")); + const seams = Object.entries(api) + .filter(([, value]) => isApi(value)) + .map(([name]) => name); + expect(seams.length > 0).toBe(true); + expect(seams).toContain("Git"); + + for (const specifier of ["@executablemd/git", "@executablemd/git/deno"]) { + const published = yield* until(import(specifier)); + const names = Object.keys(published); + expect(`${specifier}: ${names.length > 0}`).toBe(`${specifier}: true`); + const leaked = Object.entries(published) + .filter(([, value]) => isApi(value)) + .map(([name]) => name) + .sort(); + expect(`${specifier}: ${leaked.join(",")}`).toBe(`${specifier}: `); + } + }); + it("publishes no package-local seam from either entrypoint", function* () { for (const specifier of ["@executablemd/git", "@executablemd/git/deno"]) { const published = yield* until(import(specifier)); diff --git a/packages/git/tests/pull-request-durability.test.ts b/packages/git/tests/pull-request-durability.test.ts index 0ac4e517a..3d5a2337d 100644 --- a/packages/git/tests/pull-request-durability.test.ts +++ b/packages/git/tests/pull-request-durability.test.ts @@ -15,8 +15,8 @@ import { expect } from "@executablemd/test-support/expect"; import { Ok, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { GitOperationProtocolError } from "../src/composition/errors.ts"; -import { GitHost } from "../src/git-host/api.ts"; -import type { GitHostCall } from "../src/git-host/api.ts"; +import { GitHost } from "@executablemd/git/api"; +import type { GitHostCall } from "@executablemd/git/api"; import { GitHostProtocolError } from "../src/git-host/errors.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; import { PULL_REQUEST } from "../src/composition/pull-request-records.ts"; diff --git a/packages/git/tests/pull-request-read.test.ts b/packages/git/tests/pull-request-read.test.ts index e0cf97df6..3f786e72a 100644 --- a/packages/git/tests/pull-request-read.test.ts +++ b/packages/git/tests/pull-request-read.test.ts @@ -17,8 +17,8 @@ import { exists } from "@effectionx/fs"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import type { Operation } from "effection"; -import type { PullRequestReadResult } from "../src/composition/pull-request-read-records.ts"; -import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +import type { PullRequestReadResult } from "@executablemd/git/api"; +import { PullRequestAPI } from "@executablemd/git/api"; import { parseGitHubPullRequestUrl, recognizesGitHubPullRequestUrl, diff --git a/packages/git/tests/pull-request.test.ts b/packages/git/tests/pull-request.test.ts index 9394678b5..a9057f145 100644 --- a/packages/git/tests/pull-request.test.ts +++ b/packages/git/tests/pull-request.test.ts @@ -17,7 +17,7 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped, type Operation } from "effection"; import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; import type { ComponentRegistration } from "@executablemd/core"; -import { RepositoryContext } from "../src/composition/context.ts"; +import { RepositoryContext } from "@executablemd/git/api"; import { GitOperationAdmissionError, PullRequestAdmissionError, diff --git a/packages/git/tests/run-composition-ambient.test.ts b/packages/git/tests/run-composition-ambient.test.ts index f54f15970..423a769ef 100644 --- a/packages/git/tests/run-composition-ambient.test.ts +++ b/packages/git/tests/run-composition-ambient.test.ts @@ -20,7 +20,7 @@ import { API, cwd } from "@executablemd/runtime"; import { spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { selectedRepository } from "../src/composition/context.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; import type { ComponentRegistration } from "@executablemd/core"; import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; diff --git a/packages/git/tests/run-composition-lazy.test.ts b/packages/git/tests/run-composition-lazy.test.ts index 1b10ae747..b20088060 100644 --- a/packages/git/tests/run-composition-lazy.test.ts +++ b/packages/git/tests/run-composition-lazy.test.ts @@ -26,7 +26,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { useHostFiles } from "@executablemd/runtime"; import { useRunComposition } from "../src/deno/run-composition/provider.ts"; import { useCompositionComponents } from "../src/composition/installation.ts"; -import { RepositoryComposition } from "../src/composition/api.ts"; +import { RepositoryComposition } from "@executablemd/git/api"; import type { RepositoryHost, GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; /** Every acquisition this provider can make, counted where it happens. */ diff --git a/packages/git/tests/run-composition-remote.test.ts b/packages/git/tests/run-composition-remote.test.ts index 5c66363d8..51660ef34 100644 --- a/packages/git/tests/run-composition-remote.test.ts +++ b/packages/git/tests/run-composition-remote.test.ts @@ -16,13 +16,13 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { type Operation } from "effection"; import { admitLivePushEvidence } from "../src/deno/run-composition/operations.ts"; -import { GitComposition } from "../src/composition/git-api.ts"; -import type { GitPushOutcome } from "../src/composition/git-push-records.ts"; +import { GitComposition } from "@executablemd/git/api"; +import type { GitPushOutcome } from "@executablemd/git/api"; import { LivePushEvidenceError } from "../src/deno/run-composition/errors.ts"; import { git, remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; import type { BareRemote } from "./support/git-remotes.ts"; import { selectedRepository } from "../src/composition/context.ts"; -import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositorySelection } from "@executablemd/git/api"; import { gitHubSource } from "../src/deno/composition/github.ts"; import { creations, diff --git a/packages/workflow/tests/retained-run.test.ts b/packages/workflow/tests/retained-run.test.ts index 7bd6991a0..4a8cb8dec 100644 --- a/packages/workflow/tests/retained-run.test.ts +++ b/packages/workflow/tests/retained-run.test.ts @@ -36,7 +36,7 @@ import { } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionRequest } from "@executablemd/core"; -import { Git } from "../../git/src/git.ts"; +import { Git } from "@executablemd/git/api"; import { getWorkflowRun, retainedWorkflowInstallation } from "../src/run.ts"; import type { WorkflowRun } from "../src/run.ts"; diff --git a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts index 118ffa98e..a9158c08a 100644 --- a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts +++ b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts @@ -27,7 +27,7 @@ import { exec } from "@effectionx/process"; import { when } from "@effectionx/converge"; import type { Close, DurableEvent, Yield } from "@executablemd/durable-streams"; import { SOURCE_POSITION_FIELD } from "@executablemd/core"; -import { Git } from "../../git/src/git.ts"; +import { Git } from "@executablemd/git/api"; import { isGitWorkflowRunRecord, WorkflowDatabaseFormatError, diff --git a/packages/workflow/tests/workflow-run.test.ts b/packages/workflow/tests/workflow-run.test.ts index c1647f944..d5faa5f31 100644 --- a/packages/workflow/tests/workflow-run.test.ts +++ b/packages/workflow/tests/workflow-run.test.ts @@ -31,7 +31,7 @@ import { createApi } from "@effectionx/context-api"; import type { Api } from "@effectionx/context-api"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { Git } from "../../git/src/git.ts"; +import { Git } from "@executablemd/git/api"; import { createWorkflowRunInstallation, getWorkflowRun } from "../src/run.ts"; import { workflowInstallation } from "../../git/src/installation.ts"; import { describeGitWorkflowRun } from "../src/journal.ts"; diff --git a/packages/workflow/tests/workspace-effect-loaded-copy.test.ts b/packages/workflow/tests/workspace-effect-loaded-copy.test.ts index 7e45378c6..c8e66983d 100644 --- a/packages/workflow/tests/workspace-effect-loaded-copy.test.ts +++ b/packages/workflow/tests/workspace-effect-loaded-copy.test.ts @@ -36,30 +36,25 @@ import { createDurableWorkspaceOperation } from "../mod.ts"; import { retainedWorkflowInstallation } from "../src/run.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import type { WorkflowRun } from "../src/run.ts"; -import { - GIT_HOST_EFFECT, - reconcileGitHostEffect, - withGitHostProvider, -} from "../../git/src/git-host/effect.ts"; -import { GIT_HOST_API, GitHost } from "../../git/src/git-host/api.ts"; -import type { - GitHostApi, - GitHostCall, - GitHostProvider, - GitHostRoutingRequest, -} from "../../git/src/git-host/api.ts"; +import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect.ts"; +import { reconcileGitHostEffect, withGitHostProvider } from "@executablemd/git/api"; +import { GIT_HOST_API, GitHost } from "@executablemd/git/api"; +import type { GitHostCall, GitHostProvider, GitHostRoutingRequest } from "@executablemd/git/api"; +import type { GitHostApi } from "@executablemd/git/api"; import { GitHostProviderError } from "../../git/src/git-host/errors.ts"; import { gitHostRequestFingerprint, parseGitHostReconciliationRecord, } from "../../git/src/git-host/records.ts"; import type { - CompleteGitHostEffectRequest, - GitHostCompletion, GitHostEffectRequest, - GitHostObservation, GitHostReconciliationRecord, } from "../../git/src/git-host/records.ts"; +import type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostObservation, +} from "@executablemd/git/api"; /** * The admissions the Git Plugin contributes, as a host installing it receives diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60334ec09..8cc5691b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:packages/durable-streams + '@executablemd/git': + specifier: workspace:* + version: link:packages/git '@executablemd/runtime': specifier: workspace:* version: link:packages/runtime From 83064c5ba23b81f02df98734345ca732b8899f28 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 17:38:02 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Rename=20the=20ambient?= =?UTF-8?q?=20Repository=20operation=20to=20repository()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RepositoryComposition.operations.ambientRepository()` read as though "ambient" were a kind of repository the caller chose. It is not: the operation answers one question — which Repository is this element acting on when it was written outside a lexical `` — and "ambient" describes where the answer came from, not what was asked for. The three answers are unchanged: a selection when the profile has an ambient Repository and this invocation is in one, a throw naming how to run inside one when it has them and this invocation is not, and `undefined` when the profile has no such thing at all. Nothing durable carries the name. It appears in no journal, fixture, snapshot or retained record, so this is a source rename with no compatibility surface. One of the six sites is in a module `grep` calls binary and skips without saying so; the typecheck is what found it. --- packages/git/api.ts | 2 +- packages/git/src/composition/api.ts | 4 ++-- packages/git/src/composition/context.ts | 2 +- packages/git/src/deno/composition/provider.ts | 2 +- packages/git/src/deno/run-composition/provider.ts | 2 +- packages/git/tests/run-composition-lazy.test.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/git/api.ts b/packages/git/api.ts index c510b1d74..f9395a90f 100644 --- a/packages/git/api.ts +++ b/packages/git/api.ts @@ -20,7 +20,7 @@ * import { Git, RepositoryComposition } from "@executablemd/git/api"; * * // Call one. - * const repository = yield* RepositoryComposition.operations.ambientRepository(); + * const ambient = yield* RepositoryComposition.operations.repository(); * * // Or answer it. Providers install at `min` so a nested replacement wins * // rather than being shadowed by an outer handler. diff --git a/packages/git/src/composition/api.ts b/packages/git/src/composition/api.ts index ceda41402..b81772aa7 100644 --- a/packages/git/src/composition/api.ts +++ b/packages/git/src/composition/api.ts @@ -86,7 +86,7 @@ export interface RepositoryCompositionApi { * has no such thing at all — a workflow document names its repositories, and * the component's own refusal is what says so. */ - ambientRepository(): Operation; + repository(): Operation; } export const RepositoryComposition: Api = @@ -103,7 +103,7 @@ export const RepositoryComposition: Api = throw new RepositoryCompositionProviderError(""); }, // deno-lint-ignore require-yield - *ambientRepository(): Operation { + *repository(): Operation { throw new RepositoryCompositionProviderError("an element written outside a "); }, }); diff --git a/packages/git/src/composition/context.ts b/packages/git/src/composition/context.ts index 532b20609..eeff02def 100644 --- a/packages/git/src/composition/context.ts +++ b/packages/git/src/composition/context.ts @@ -52,5 +52,5 @@ export function* selectedRepository(): Operation { + *repository(): Operation { return undefined; }, }, diff --git a/packages/git/src/deno/run-composition/provider.ts b/packages/git/src/deno/run-composition/provider.ts index 44f86631d..814cb8cca 100644 --- a/packages/git/src/deno/run-composition/provider.ts +++ b/packages/git/src/deno/run-composition/provider.ts @@ -362,7 +362,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { + *repository(): Operation { const { selection: ambientSelection } = yield* useAmbient(); if (ambientSelection === undefined) { // This profile *has* ambient repositories; this invocation is not in diff --git a/packages/git/tests/run-composition-lazy.test.ts b/packages/git/tests/run-composition-lazy.test.ts index b20088060..3260fe800 100644 --- a/packages/git/tests/run-composition-lazy.test.ts +++ b/packages/git/tests/run-composition-lazy.test.ts @@ -150,7 +150,7 @@ describe("what the ordinary repository provider acquires, and when", () => { /** Whether an ambient repository was found, asked through the public Api. */ function* ambient(): Operation { try { - yield* RepositoryComposition.operations.ambientRepository(); + yield* RepositoryComposition.operations.repository(); return true; } catch { return false;