diff --git a/architecture.md b/architecture.md index 928ec568..8052a89f 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 e07a9714..7941dd3d 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 28b50409..85d50f9f 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 4f819432..cc19e101 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 84063db4..e57f04b0 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 99bdb346..d1a2d6f8 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 2164be52..c0fe9bce 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 180e8ccd..423027a7 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 00000000..f9395a90 --- /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 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. + * 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 b6178b60..d25e314f 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 4d8882bd..debf7f90 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 c4da9d32..368b6d4d 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/composition/api.ts b/packages/git/src/composition/api.ts index ceda4140..b81772aa 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 532b2060..eeff02de 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 44f86631..814cb8cc 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/src/issue/context.ts b/packages/git/src/issue/context.ts index e54ec6c1..dc069c1a 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 1829e491..fd843b68 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 00000000..d5784f77 --- /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 7d6d690d..c26e5e5d 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 aed35512..84d83e0c 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 7163f74c..32f4b932 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 9110386f..f01eccce 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 f3ed0163..9bbc3876 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 e35f5f6c..02f3a322 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 ae419e61..e94e7eb3 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 6aae9d47..9123e677 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 9821d30d..fdd2d5a3 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 966dda1d..484a8319 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 a791f8ec..6901bb35 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 824895a9..9d5dc5c4 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 89297028..d618bbb6 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 9d62471a..e39a0b4c 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 9b817998..771f4303 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 d9f44eeb..d965e504 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 0ac4e517..3d5a2337 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 e0cf97df..3f786e72 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 9394678b..a9057f14 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 f54f1597..423a769e 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 1b10ae74..3260fe80 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. */ @@ -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; diff --git a/packages/git/tests/run-composition-remote.test.ts b/packages/git/tests/run-composition-remote.test.ts index 5c66363d..51660ef3 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 7bd6991a..4a8cb8de 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 118ffa98..a9158c08 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 c1647f94..d5faa5f3 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 7e45378c..c8e66983 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 60334ec0..8cc5691b 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