From 6beb41226dfc40866cf554113b4b1e56370cfdc0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 15:53:55 -0400 Subject: [PATCH 01/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Separate=20Plan=20sy?= =?UTF-8?q?ntax=20observation=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 2 +- packages/cli/src/documents/Plan.md | 3 +- packages/cli/src/plan-component.ts | 100 +++++++++++++----- packages/cli/tests/plan-cli.test.ts | 2 +- packages/cli/tests/plan-component.test.ts | 61 ++++++++++- scripts/tests/cli-npm-bin.test.ts | 1 + scripts/tests/plan-component-compiled.test.ts | 1 + specs/executable-mdx-spec.md | 14 ++- specs/plan-command-spec.md | 42 ++++---- 9 files changed, 172 insertions(+), 54 deletions(-) diff --git a/architecture.md b/architecture.md index b227d881..ca2d0eca 100644 --- a/architecture.md +++ b/architecture.md @@ -123,7 +123,7 @@ Existing documents and code get aligned to this section retroactively. | provider partition | one complete, independently owned agent-provider state — runtime, store, managed sessions, queues, coordinator, teardown — selected by the one installed factory at each dispatch. Production is the single-partition case of the same path; holding a partition grants work, never permission | | `JournalProvenance` | a non-operational, equality-only witness that a live publication stream descends from the exact journal backend a provider selected for one workflow run; it grants no append, read, execution, publication or reconciliation capability, and is meaningful only because the provider retains the witness it established and later requires exact equality | | result object | the value a component binds instead of failing: `{ok: true, value}` or `{ok: false, …}`, whose failure members the component declares | -| syntax catalog | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format | +| syntax catalog | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format. Packaged `` reaches that observation through its private `` capability and durably freezes the exact catalog shown to its Agent; `` separately retains the instruction identity and session facts | | run profile declarations | the component registrations a first-party package makes, held as plain values apart from the middleware, providers, activation and launchers its installer also arranges. The installer registers exactly those values and inspection reads exactly those values, so what a run installs and what the catalog reports cannot drift | | origin-only | the inspectability of a component whose contract could only be learned by loading it: a repository TypeScript module, whose schemas live on its exports and whose top level would run. Such an entry carries name, category, origin and source kind, and no contract field at all — an absent contract is stated, never rendered as an empty one | | definition-owned return state | which value body a `` selects for: one ephemeral state per execution of one value root or Markdown value component. Structural directives keep the ambient one, a component invocation hides it from the invoked body, a nested value body installs its own, and caller-projected content restores the caller's. It travels down the expansion call stack as a local rather than through a context, and no exported function accepts another body's, so nothing a document can read, replace, or import acts on a live one. The first claim on it is atomic, so a second executed return fails the body rather than replacing its value, and it appends no durable event | diff --git a/packages/cli/src/documents/Plan.md b/packages/cli/src/documents/Plan.md index d30e0a84..81a2d615 100644 --- a/packages/cli/src/documents/Plan.md +++ b/packages/cli/src/documents/Plan.md @@ -71,7 +71,8 @@ word a person reads, for the same reason. Getting the available XMD components and constructs and setting up the planning session. - + + ## Say what this surface calls things diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 31f4b12f..22473dc5 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -1,5 +1,5 @@ /** - * `` — how this host declares the component, and the five private + * `` — how this host declares the component, and the six private * capabilities only its own bytes may write. * * The Component itself is `src/documents/Plan.md` rather than anything here: @@ -27,9 +27,9 @@ * * ## Why the capabilities are private * - * ``, ``, ``, `` and - * `` are the phases of one invocation, not components anyone composes - * with. Freezing the inputs, installing a constrained Agent frame, telling an + * ``, ``, ``, ``, `` + * and `` are operations of one invocation, not components anyone + * composes with. Freezing the inputs, installing a constrained Agent frame, telling an * operator which phase is running, answering about a draft and admitting the * approved bytes are each meaningless outside the workflow that orders them — * and each carries authority the enclosing document does not have. @@ -234,19 +234,26 @@ const INPUTS_RETURNS = { }; /** - * What the frozen inputs are given: the caller's optional session name, and the - * prompt this invocation is about. + * What the frozen inputs are given: the caller's optional session name, the + * prompt this invocation is about, and the syntax `` already froze. * - * The prompt is here so that the first durable record of the invocation is - * about a question as well as a catalog. Only its digest is kept. + * Only the prompt's digest is retained here. The syntax has its own durable + * record, so each protocol can be read and reconciled independently. */ const INPUTS_PROPS = { type: "object", properties: { session: { type: "string", minLength: 1 }, instruction: { type: "string" }, + syntax: { type: "string" }, }, - required: ["instruction"], + required: ["instruction", "syntax"], + additionalProperties: false, +}; + +const NO_PROPS = { + type: "object", + properties: {}, additionalProperties: false, }; @@ -347,6 +354,7 @@ export function* planComponentDeclaration( // formatting it as Markdown would publish bytes nobody approved. exact: true, privates: [ + planSyntax(assembly), planInputs(assembly), planAuthorship(assembly), planProgress(assembly), @@ -403,6 +411,12 @@ export function* planComponentDescription(): Operation[] = [ + { + name: "Syntax", + props: NO_PROPS, + returns: { type: "string" }, + forms: ["self-closing"], + }, { name: "PlanInputs", props: INPUTS_PROPS, @@ -429,17 +443,53 @@ function* uninvocable(): Operation { ); } +/** Read the author-visible run vocabulary supplied by this Plan's host. */ +function planSyntax(assembly: PlanComponentAssembly): IdentityComponent { + return { + name: "Syntax", + origin: `${PLAN_ORIGIN}#Syntax`, + forms: ["self-closing"], + props: NO_PROPS, + returns: { type: "string" }, + factory: (claim: IdentityClaimant) => + function* Syntax( + _props: Record, + invocation: ComponentInvocation, + ): Operation { + const id = yield* claim(invocation); + const frozen = yield* durablePlanOperation(`plan:syntax:${id}`, function* () { + return { syntax: yield* assembly.catalog() }; + }); + const retained = readSyntax(frozen); + if (retained === undefined) { + throw new StaleInputError(UNREADABLE_SYNTAX); + } + return retained; + }, + }; +} + +function readSyntax(value: Json): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const { syntax } = value; + if (Object.keys(value).length !== 1 || typeof syntax !== "string") { + return undefined; + } + return syntax; +} + +const UNREADABLE_SYNTAX = + "the retained Plan syntax cannot be read as syntax, so no Plan source was produced."; + /** * Freeze this invocation's authorship inputs, and retain them. * - * The catalog is an observation the first Agent turn is built from, so it is - * journaled: a continuation restores what the run actually showed the agent - * rather than rebuilding one from a working tree that has moved. The instruction - * identity beside it is what makes a continuation answerable at all: this is the - * first durable record of the invocation, so comparing it here refuses a Plan - * asked for different instructions before a directory, a provider, a turn, a - * review or an admission exists — the only place that can refuse without having - * already done some of the work it would be refusing. + * The syntax is the retained observation `` supplied. The instruction + * identity here makes a continuation answerable at all: comparing it refuses a + * Plan asked under another prompt before a directory, a provider, a turn, a + * review or an admission exists. * * The session placement is derived here too, from the durable identity canonical * execution minted for this exact expansion — which is what makes two `` @@ -467,9 +517,10 @@ function planInputs(assembly: PlanComponentAssembly): IdentityComponent { const session = placementFor(assembly, id, authored); const instruction = sourceDigest(String(props.instruction)); + const syntax = String(props.syntax); const frozen = yield* durablePlanOperation(`plan:inputs:${id}`, function* () { - return { syntax: yield* assembly.catalog(), instruction }; + return { instruction }; }); // A history is input, so it is parsed rather than trusted. @@ -482,7 +533,7 @@ function planInputs(assembly: PlanComponentAssembly): IdentityComponent { } return { - syntax: retained.syntax, + syntax, session, surface: assembly.surface, durable: durability(assembly, authored), @@ -692,14 +743,13 @@ function checkDraft(validate: StructuralValidation): IdentityComponent { /** What the frozen inputs retained, or nothing when the record is not one. */ interface RetainedInputs { - readonly syntax: string; readonly instruction: string; } /** * The frozen inputs a record holds, read as a closed protocol. * - * Exactly two members, both strings. A record missing one, carrying a member + * Exactly one string member. A record missing it, carrying a member * this version does not know, or holding one of the wrong type is a record this * version cannot read — not one to fill in a default for, because every default * here is a guess about what an earlier run actually asked. @@ -708,14 +758,14 @@ function readInputs(value: Json): RetainedInputs | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; } - const { syntax, instruction } = value; - if (Object.keys(value).length !== 2) { + const { instruction } = value; + if (Object.keys(value).length !== 1) { return undefined; } - if (typeof syntax !== "string" || typeof instruction !== "string") { + if (typeof instruction !== "string") { return undefined; } - return { syntax, instruction }; + return { instruction }; } /** diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index fb732f64..9959ef7f 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -803,7 +803,7 @@ describe( "No Plan was returned. Nothing was output.", ); // Refused before the catalog, a directory, a provider, a turn or a - // review existed. The catalog is built by `` now, and this + // review existed. The catalog is built by private ``, and this // refusal happens before the command document starts at all. expect(untouched(harness)).toEqual({ catalogs: 0, diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 63e7cd44..7de8d12d 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -308,7 +308,14 @@ describe("Tier PC — in an ordinary document", () => { it("PC6: the private capabilities resolve nowhere a document can write", function* () { yield* useWorkingDirectory(function* () { - for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { + for (const name of [ + "Syntax", + "PlanInputs", + "PlanAuthorship", + "PlanProgress", + "CheckDraft", + "AdmitPlan", + ]) { const run = yield* runDocument({ source: [`<${name} as="x" />`, ""].join("\n"), reviews: [], @@ -338,7 +345,14 @@ describe("Tier PC — in an ordinary document", () => { for (const category of catalog.categories) { const names = category.entries.map((entry) => entry.name); - for (const priv of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { + for (const priv of [ + "Syntax", + "PlanInputs", + "PlanAuthorship", + "PlanProgress", + "CheckDraft", + "AdmitPlan", + ]) { expect(names).not.toContain(priv); } } @@ -421,11 +435,20 @@ describe("Tier PC — in an ordinary document", () => { // differently and a review nobody scripted. Neither is reached: the turn, // the check, the approval and the admission are all restored. const partial = yield* continuing(first); + let catalogs = 0; const two = yield* runDocument({ source, reply: "# A different Plan\n\nnot this one.\n", reviews: [], stream: partial, + harness: yield* planDeclarationHarness({ + surface: "component", + authorshipRoot: yield* authorshipRoot(), + *catalog() { + catalogs += 1; + throw new Error("a restored syntax snapshot was rebuilt"); + }, + }), }); expect(two.failure).toBe(undefined); @@ -433,6 +456,7 @@ describe("Tier PC — in an ordinary document", () => { expect(two.harness.fake.prompts).toEqual([]); expect(two.harness.reviews).toEqual([]); expect(two.harness.checked).toEqual([]); + expect(catalogs).toBe(0); }); }); @@ -978,7 +1002,7 @@ describe("Tier PC — in an ordinary document", () => { // a member of the wrong type. Each is refused with the same fixed // sentence, and none of them produces source or a binding. const cases: [string, (value: Json) => Json][] = [ - ["a member is missing", (value) => ({ syntax: Object(value).syntax })], + ["the member is missing", () => ({})], [ "a member this version does not know was added", (value) => ({ ...Object(value), extra: "surprise" }), @@ -999,6 +1023,37 @@ describe("Tier PC — in an ordinary document", () => { }); }); + it("PC27: the private syntax snapshot is closed and hostile records produce nothing", function* () { + yield* useWorkingDirectory(function* () { + const approved = yield* approvedRun(); + const syntax = (yield* approved.readAll()).find( + (event) => event.type === "yield" && event.description.name.startsWith("plan:syntax:"), + ); + expect(syntax?.type).toBe("yield"); + if (syntax?.type !== "yield" || syntax.result.status !== "ok") { + throw new Error("the approved run retained no syntax snapshot"); + } + const value = Object(syntax.result.value); + expect(Object.keys(value)).toEqual(["syntax"]); + expect(typeof value.syntax).toBe("string"); + + const cases: [string, (value: Json) => Json][] = [ + ["the member is missing", () => ({})], + ["an unknown member was added", (record) => ({ ...Object(record), extra: true })], + ["the member has the wrong type", () => ({ syntax: 7 })], + ]; + + for (const [, replace] of cases) { + const run = yield* continued(yield* tampered(approved, "plan:syntax:", replace)); + expect(run.failure).toContain("retained Plan syntax cannot be read as syntax"); + expect(run.output).not.toContain("got:"); + expect(run.output).not.toContain("# Say hello"); + expect(run.harness.fake.prompts).toEqual([]); + expect(run.harness.reviews).toEqual([]); + } + }); + }); + it("PC26: a retained artifact this version cannot read produces nothing", function* () { yield* useWorkingDirectory(function* () { const cases: [string, (value: Json) => Json][] = [ diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index af7caefc..90e17e55 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -221,6 +221,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(plan.forms).toEqual(["paired"]); // And no private capability is syntax a document may write, in any build. for (const name of [ + "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 67a22291..7bb090af 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -91,6 +91,7 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => // write. const names = entries.map((entry: { name?: string }) => entry?.name); for (const name of [ + "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 64eca172..f91b4548 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2842,11 +2842,15 @@ it emits that source where the component is written, and `as` is ordinary text capture: the same bytes are bound and nothing is emitted. Neither form evaluates the source, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, and an ordinary `` -expands no progress body at all. Its five private -capabilities — ``, ``, ``, -`` and +expands no progress body at all. Its six private capabilities — ``, +``, ``, ``, `` and `` — are the closure those exact bytes carry, and are syntax no -document may write. [The plan command](./plan-command-spec.md) is the contract. +document may write. `` observes and durably freezes the host-supplied run +vocabulary before `` records the instruction identity and session +facts. Their retained records are separate closed protocols, exactly +`{ syntax }` and `{ instruction }`; malformed members refuse before authorship, +and continuation restores the catalog already shown rather than rebuilding it. +[The plan command](./plan-command-spec.md) is the contract. **Which Agent a Plan is written with is the host's to say, not the Component's.** The declaration carries a trusted-host capability: the agent a Plan conversation @@ -10813,7 +10817,7 @@ rather than restating. | PO6/PO7 | Channels and grammar | A non-terminal stderr receives normalized Markdown and a stated terminal receives it rendered, while stdout and `--output` stay byte-identical; `--verbose` and `--journal` work on either side of the request, help carries them and the journal warning, and the short aliases, every removed spelling and a retained option that reaches this grammar written where the journal path goes all refuse before any work, while `--help` keeps its ordinary precedence | | PO8/PO9/PO16 | The journal file | No `--journal` writes no file; one creates the path before the catalog and the first turn, parses as the existing JSONL in commit order, ends terminally and holds no program execution; an existing path and an uncreatable one each report their exact refusal and reach nothing; and an ordinary failure — where no append failed — leaves a wholly parseable file with no partial trailing record | | PO10–PO12 | The secret and persistence boundaries | A secret in a draft or in a failed check's findings reaches neither the progress nor the file while the earlier prefix stays readable, and the same values without it are shown and recorded; a refused entry reports the exact journal-write diagnostic and preserves what committed | -| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is built once, from `` | +| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is built once, from private ``, while continuation restores that snapshot without rebuilding it | ### Tier UG — The `xmd upgrade` command diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 2f380cc5..0a6d0fca 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -342,9 +342,9 @@ The host supplies two fixed internal inputs as that root's props: They are the adapter's own, and nothing a Plan declares is bound here: the properties a Plan's root declares are resolved by whoever runs it. The catalog -is not among them: it is built inside ``, from a closure the host -captured, so an authored phase can say that the preparation is starting before -it happens. +is not among them: private `` observes it through a closure the host +captured, so an authored phase can say that preparation is starting before the +observation happens. **The root is an adapter, not the workflow.** Its whole body is two elements: it projects `props.request` into `` without adding whitespace, supplies @@ -388,20 +388,26 @@ surfaces' endings, each written once. The command's wording is unchanged; the component's says that no Plan was returned rather than that nothing was output or run. TypeScript supplies neither the words nor the choice between them. -**The five private capabilities.** The Component's phases are components only these -exact bytes may write, declared by the host with the definition and revoked with -the execution: `` builds and freezes the catalog, the instruction -identity, the -session placement, the surface and whether that placement outlives the -invocation, and refuses a continuation whose instructions render differently — -as stale input, before a directory, a provider, a turn or a review exists; paired -`` installs the constrained frame and does not return until every -part of it has torn down; paired `` says which phase is running; -`` answers about one draft without -executing it; and `` structurally admits the approved bytes after that -teardown and retains them as one Plan artifact — the invocation identity, the -instruction identity, the approved source, its digest and that successful -admission — before the Component renders them. +**The six private capabilities.** They are components only these exact bytes may +write, declared by the host with the definition and revoked with the execution. +`` observes the host-supplied run vocabulary +and retains the exact catalog the Agent receives. `` freezes the +instruction identity, session placement, surface and whether that placement +outlives the invocation, and refuses a continuation whose instructions render +differently — as stale input, before a directory, a provider, a turn or a review +exists. Paired `` installs the constrained frame and does not +return until every part of it has torn down; paired `` says which +phase is running; `` answers about one draft without executing it; +and `` structurally admits the approved bytes after that teardown and +retains them as one Plan artifact — the invocation identity, the instruction +identity, the approved source, its digest and that successful admission — before +the Component renders them. + +The syntax snapshot and Plan inputs are separate closed durable protocols. +`` retains exactly `{ syntax }`; `` retains exactly +`{ instruction }`. A continuation restores the catalog it actually showed the +Agent rather than observing a moved component environment, while a missing, +additional or mistyped member in either record refuses before authorship begins. Whether the placement is durable is carried across that boundary rather than re-derived, because `` is the last thing that sees the public @@ -990,4 +996,4 @@ neither observation never interpreted what it wrote. | PO16 | An ordinary failure | A journal-backed invocation that fails for its own reason — a failed turn, with neither a secret rejection nor a write failure — exits non-zero, delivers no source and no artifact, completes teardown, and leaves a file whose every entry parses and whose bytes are exactly those entries re-serialized: no append failed, so there is no partial or unterminated trailing record | | PO13 | A failed destination | A consumer that fails while a turn is live cancels that turn, waits for every owned teardown, attempts no artifact sink, keeps the bytes stderr accepted, and uses the exact progress-failure diagnostic | | PO14 | Ordering is unchanged | Cancellation, teardown failure, final validation refusal, the `--output` refusal and a successful delivery all keep their order, and no phase claims an artifact was delivered | -| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is built exactly once, from ``, after Preparing | +| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is built exactly once, from private ``, after Preparing; continuation restores that snapshot without rebuilding it | From c74b3b5f65bb4ada4397b4dac177e10dbbbbc9c2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 20:38:07 -0400 Subject: [PATCH 02/17] =?UTF-8?q?=E2=9C=A8=20Make=20``=20a=20p?= =?UTF-8?q?ublic=20protected=20component=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a document may write here is a public question, and this makes the answer public too. `` is now a component canonical core owns, available throughout XMD: it renders the catalog for the site it is written at, in the Markdown `xmd syntax` prints, from one construction and one renderer — so an operator printing a profile and an agent being told what to write are never given different accounts of one environment. A new canonical protected tier sits after structural syntax and ahead of every host or author tier. It is the resolver's own table rather than a registration, so a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration and a host's declared Markdown are each refused where the claim is made, and import middleware may observe, delegate or refuse the import without answering one. Canonical execution carries the catalog lexically on its own expansion authority, built from the selection inputs it captured before any installation, middleware or document code ran — or from the one catalog a trusted host stated for its profile. Each occurrence claims the identity the execution minted, observes once, and retains exactly `{ catalog }`; a continuation hostile-parses that record and restores what the run actually showed. `` returns to five private capabilities. The packaged bytes write the same public `` any document writes, and `xmd plan` states the `run` profile's catalog at the execution boundary rather than through Plan's private assembly. --- architecture.md | 126 ++- packages/cli/src/authorship-profile.ts | 25 +- packages/cli/src/cli.ts | 5 - packages/cli/src/documents/Plan.md | 4 +- packages/cli/src/plan-component.ts | 98 +-- packages/cli/src/plan.ts | 14 +- packages/cli/src/syntax.ts | 246 +----- packages/cli/tests/plan-cli.test.ts | 5 +- .../cli/tests/plan-command-document.test.ts | 30 +- packages/cli/tests/plan-component.test.ts | 114 ++- packages/cli/tests/support/plan-harness.ts | 72 +- .../cli/tests/support/run-markdown-tier.ts | 4 - packages/cli/tests/syntax-cli.test.ts | 63 ++ .../cli/tests/testing-execution-host.test.ts | 44 +- packages/core/host.ts | 5 + packages/core/mod.ts | 7 + packages/core/src/components/Syntax.ts | 178 ++++ packages/core/src/components/bundle.ts | 11 + .../core/src/components/declared-markdown.ts | 4 + .../core/src/components/import-authority.ts | 32 +- packages/core/src/components/protected.ts | 133 +++ packages/core/src/components/registration.ts | 4 + packages/core/src/components/select.ts | 22 +- packages/core/src/document-validation.ts | 19 + packages/core/src/execute.ts | 106 ++- packages/core/src/expand.ts | 13 + packages/core/src/inspect.ts | 84 +- packages/core/src/invocation-identity.ts | 142 ++- packages/core/src/syntax-markdown.ts | 251 ++++++ packages/core/src/syntax-observation.ts | 127 +++ packages/core/src/types.ts | 12 + packages/core/tests/syntax-catalog.test.ts | 8 +- packages/core/tests/syntax-component.test.ts | 818 ++++++++++++++++++ .../core/tests/syntax-loaded-copy.test.ts | 222 +++++ scripts/runtime-test-exclusions.ts | 6 + scripts/tests/cli-npm-bin.test.ts | 18 +- scripts/tests/plan-component-compiled.test.ts | 19 +- specs/executable-mdx-spec.md | 85 +- specs/plan-command-spec.md | 58 +- 39 files changed, 2793 insertions(+), 441 deletions(-) create mode 100644 packages/core/src/components/Syntax.ts create mode 100644 packages/core/src/components/protected.ts create mode 100644 packages/core/src/syntax-markdown.ts create mode 100644 packages/core/src/syntax-observation.ts create mode 100644 packages/core/tests/syntax-component.test.ts create mode 100644 packages/core/tests/syntax-loaded-copy.test.ts diff --git a/architecture.md b/architecture.md index ca2d0eca..5acdf97b 100644 --- a/architecture.md +++ b/architecture.md @@ -123,7 +123,8 @@ Existing documents and code get aligned to this section retroactively. | provider partition | one complete, independently owned agent-provider state — runtime, store, managed sessions, queues, coordinator, teardown — selected by the one installed factory at each dispatch. Production is the single-partition case of the same path; holding a partition grants work, never permission | | `JournalProvenance` | a non-operational, equality-only witness that a live publication stream descends from the exact journal backend a provider selected for one workflow run; it grants no append, read, execution, publication or reconciliation capability, and is meaningful only because the provider retains the witness it established and later requires exact equality | | result object | the value a component binds instead of failing: `{ok: true, value}` or `{ok: false, …}`, whose failure members the component declares | -| syntax catalog | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format. Packaged `` reaches that observation through its private `` capability and durably freezes the exact catalog shown to its Agent; `` separately retains the instruction identity and session facts | +| syntax catalog | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format. `xmd syntax` prints one for an environment nobody is running; canonical `` renders one for the site an element was written at, from the same construction and the same Markdown renderer | +| catalog observation | the engine-owned lexical answer to "what may a document write here", carried by value on canonical core's expansion authority beside the import authority. The execution builds one at its root from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile; a trusted canonical evaluation boundary replaces it for the subtree it evaluates. It answers with text and carries no authority: a component named in a catalog is not a component anything may run | | run profile declarations | the component registrations a first-party package makes, held as plain values apart from the middleware, providers, activation and launchers its installer also arranges. The installer registers exactly those values and inspection reads exactly those values, so what a run installs and what the catalog reports cannot drift | | origin-only | the inspectability of a component whose contract could only be learned by loading it: a repository TypeScript module, whose schemas live on its exports and whose top level would run. Such an entry carries name, category, origin and source kind, and no contract field at all — an absent contract is stated, never rendered as an empty one | | definition-owned return state | which value body a `` selects for: one ephemeral state per execution of one value root or Markdown value component. Structural directives keep the ambient one, a component invocation hides it from the invoked body, a nested value body installs its own, and caller-projected content restores the caller's. It travels down the expansion call stack as a local rather than through a context, and no exported function accepts another body's, so nothing a document can read, replace, or import acts on a live one. The first claim on it is atomic, so a second executed return fails the body rather than replacing its value, and it appends no durable event | @@ -3557,11 +3558,14 @@ ships different bytes under a declared name therefore fails where it is installed rather than where a document happens to write the name. **The name is claimed, not offered.** Resolution places a declared component in -the same protected tier as a reserved registration, above the workflow component -bundle, repository files and every registered default. Two claims on one name -are refused rather than ordered, so the tier never chooses. A repository -`Policy.md`, a bundled `Policy`, an ordinary registration and another loaded -copy of the host package can none of them answer for a declared `Policy`. +the same host tier as a reserved registration, above the workflow component +bundle, repository files and every registered default — and below the canonical +protected tier, which is the engine's own claim rather than a host's. Two claims +on one name are refused rather than ordered, so the tier never chooses. A +repository `Policy.md`, a bundled `Policy`, an ordinary registration and another +loaded copy of the host package can none of them answer for a declared `Policy`; +and a host that declares a name canonical core owns is refused at admission, +before the root import. **Invocation is canonical, for the declared names.** A declaring execution imports through the same retention a bundled one does: canonical core keeps its @@ -3641,18 +3645,80 @@ and a name is not a secret, so a component could build one, reach the record and answer that everything is exact. It is reclaimed with its execution, reaches no public entrypoint, and marks nothing on the segments themselves. +## The canonical protected tier + +Resolution already had a protected tier — a reserved registration, or exact +Markdown a host declared. Both of those are *a host's* claim, made by whoever +assembled the run. Above them sits the engine's own, and a name in it means the +same thing in every execution: whichever host built it, whichever package +registered what, and whatever the repository holds. + +One component is in it. `` describes the vocabulary of the site it is +written at, and a description of a run's vocabulary that anything in the run +could answer for is a description of nothing — the value of the answer is +exactly that nobody but core produced it. + +**It is not a registration.** A registration is an answer a registry gives for a +name, and a registry is something a nested scope layers over, a host installs +into, and a handler can keep a record from and hand back somewhere else. So the +tier is the resolver's own table, consulted after structural syntax and before +every host or author tier, and consulted unconditionally: no option a caller +passes — or leaves out — puts a repository file, a bundle member or a +registration in front of it. + +Collision is refused where each claim is made, so the tier never has to choose: +a registration under the name is refused atomically at registration, a host's +declared Markdown and a workflow bundle member are each refused at admission +before the root import, and a repository `Syntax.md`, `Syntax.ts` or directory +candidate is never probed for because selection has already answered. + +**Invocation is canonical.** The execution mints one identity domain per +protected component, calls its factory with that domain's claimant, and registers +what comes back nowhere. Import is closed for exactly those names, on the terms a +declaration's names are closed: `Component.importComponent` middleware may +observe the import, delegate it and refuse it by throwing, and what it cannot do +is answer one — a witness is issued where canonical execution produced its answer +and verified where the component is invoked, and core's own copy is what runs. +Every other name in the execution stays the ordinary open import it has always +been. + +**Protection is about the answer, not about power.** A protected implementation +is handed the lexical catalog observation for its site and nothing else: no +component definitions, no import witness, no invocation capability, no policy +table, no provider and no registration handle. The body itself is kept in a table +private to the copy of core that built the implementation and reached only by +canonical expansion, so an implementation another loaded copy created — which is +an ordinary arrangement, because a component can be loaded from disk beside its +own copy — has no body here and no answer to give. + +The durable record follows the ordinary rules. Each occurrence claims the +identity this execution minted, performs one `syntax_catalog` observation, and +retains exactly `{ catalog: string }`. A continuation hostile-parses that record +and hands the same text back without consulting the filesystem, the registry, the +bundle, the host or the lexical observation again; a missing, additional or +mistyped member is stale input rather than a component failure, and refuses +before output or binding. Two authored occurrences are two identities and two +observations, and repeated reads of one binding observe nothing again. + ## The syntax catalog boundary `xmd syntax` answers what a document may write here, and answering must cost nothing. The boundary that makes that true is one operation with no authority. **One catalog, two projections.** Core produces a `SyntaxCatalog` — version 1, a -fixed three-category tuple, entries sorted by name — and the CLI's Markdown and -JSON renderers each take that value. Neither renderer discovers anything, and +fixed three-category tuple, entries sorted by name — and the Markdown and JSON +renderers each take that value. Neither renderer discovers anything, and neither parses the other's output, so the two formats cannot describe different -environments. JSON is the canonical, lossless projection; Markdown is written -for a person and labels a schema it cannot faithfully summarize rather than -inventing a type for it. +environments. JSON is the canonical, lossless projection and belongs to the CLI; +Markdown is written for a person and labels a schema it cannot faithfully +summarize rather than inventing a type for it. + +**The Markdown renderer is core's**, because two things print it: the command, +which describes an environment nobody is running, and canonical ``, +which hands the same text to a document that is running. One renderer is what +makes those the same bytes for the same site; two that agreed by hand would be +one release away from telling an operator and an agent different things about +one profile. **A declaration set is admitted on execution's terms, or not at all.** The identity components a host would declare to an execution are read here without @@ -3673,6 +3739,41 @@ declared origin and digest as its origin. Its private closure contributes nothing: those names are not syntax a document may write, so a catalog that listed them would describe an environment that does not exist. +**An execution carries one of its own, lexically.** The catalog a running +document observes travels by value on canonical core's private expansion +authority, beside the import authority and the identity domains — not through a +Context, because a context resolves by name and a name is not a secret, so a +document could build one and answer for the vocabulary it is shown. + +Canonical core builds it at the execution root from the selection inputs that +execution captured before any installation, middleware or document code ran: the +includes it resolves against, the registry it started with, the identity +components and exact Markdown its host declared, and the component bundle it is +closed over when it has one. A bundled name is described from the pinned bytes +already in hand — nothing is imported, executed, or read from a file to describe +one — and reported at the canonical repository-relative path that blob has in the +commit, which is where a reader of a workflow run looks for it. + +A trusted host may state the catalog its profile describes instead, on the terms +every other trusted-host value travels: captured by value with the rest of the +installation, before any installed code exists. One execution accepts one, and +two are refused rather than ordered, because ordering them would make which +profile a document observes depend on assembly order. `xmd plan` states one — a +Plan is a program a later `xmd run` executes, so the vocabulary the agent must be +shown is that profile's rather than the authorship execution's, which searches no +repository and refuses almost every capability. An ordinary run states none and +observes itself. + +Nothing is built until an occurrence asks, so a run whose document never writes +`` enumerates no includes, parses no component and reads no +frontmatter. Each ask builds afresh, which is what makes an occurrence's retained +catalog its own rather than a copy of whichever one ran first. + +A trusted canonical evaluation boundary may install a narrower observation for +the subtree it evaluates, and leaving that subtree restores the enclosing one. It +adds nothing: the catalog it installs is the one its own admission already +selected, so an entry absent from that admission is absent from the observation. + **Inspection is observation, never authority.** Producing a catalog installs only the declarative registration layer selection needs. It does not enter `execute()`, construct a durable stream, install a Files, Service, Agent or @@ -3836,7 +3937,8 @@ Status is measured against main. | Construct | Does | Status | | --- | --- | --- | -| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset | built on the #632 stack | +| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same catalog for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | +| `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, no props, and a text component: the bare form emits the catalog and the ordinary `as` captures the same text and emits nothing, while a paired spelling or an authored prop refuses before any observation. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the catalog says is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile — and it is carried lexically on canonical core's expansion authority rather than through any context. Each occurrence claims the identity the execution minted, performs one `syntax_catalog` observation, and retains exactly `{ catalog: string }`; a continuation hostile-parses that record and restores the catalog the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled observation completes its teardown and commits nothing. It carries no authority at all: a component named in a catalog is neither registered, resolved nor authorized by being named | built on this stack; the narrower observation a trusted evaluation boundary installs for its subtree is the seam #713 fills | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft and every failed check's structured findings — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | | `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index cd45e03a..9a682eff 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -45,7 +45,7 @@ import { useTerminalOutput, } from "@executablemd/core"; import type { AgentProviderOptions, Json } from "@executablemd/core"; -import type { DeclaredMarkdownComponent } from "@executablemd/core/host"; +import type { CatalogContribution, DeclaredMarkdownComponent } from "@executablemd/core/host"; import { executeInstalled, installInvocationAgentProvider } from "@executablemd/core/host"; import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; @@ -163,11 +163,21 @@ export interface AuthorshipProfile { * The `` declaration this command runs under. * * Built by the command, from the packaged Component's bytes, before the adapter - * root is imported. It carries the sealed surface, the sealed verbosity, the - * catalog this invocation will build and the Agent context it settled — none of - * which is a prop the adapter could supply or a document could reach. + * root is imported. It carries the sealed surface, the sealed verbosity and the + * Agent context it settled — none of which is a prop the adapter could supply + * or a document could reach. */ declaration: DeclaredMarkdownComponent; + /** + * The catalog this authorship describes. + * + * The `run` profile's, because a Plan is a program a later `xmd run` executes: + * deriving one from this execution — which searches no repository and refuses + * almost every capability — would describe a vocabulary the approved program + * would not have. Captured with the rest of the installation, before any + * installed code, middleware or document code runs. + */ + catalog: CatalogContribution; } /** What building the constrained provider needs, and nothing more. */ @@ -456,6 +466,13 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation` first asks, not before: an ordinary run that - // writes none never builds a catalog it has no reader for. - *catalog() { - return renderSyntaxMarkdown(yield* syntaxCatalog(include)); - }, }); const plan = yield* planDeclaration({ diff --git a/packages/cli/src/documents/Plan.md b/packages/cli/src/documents/Plan.md index 81a2d615..ce11f9f2 100644 --- a/packages/cli/src/documents/Plan.md +++ b/packages/cli/src/documents/Plan.md @@ -72,7 +72,7 @@ Getting the available XMD components and constructs and setting up the planning - + ## Say what this surface calls things @@ -160,7 +160,7 @@ out. Everything you may use is described below. Use nothing that is not here. -{inputs.syntax} +{syntax} Reply with the Plan source and nothing else. No enclosing code fence, no explanation before or after it. diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 22473dc5..49ae42f4 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -1,5 +1,5 @@ /** - * `` — how this host declares the component, and the six private + * `` — how this host declares the component, and the five private * capabilities only its own bytes may write. * * The Component itself is `src/documents/Plan.md` rather than anything here: @@ -27,15 +27,20 @@ * * ## Why the capabilities are private * - * ``, ``, ``, ``, `` - * and `` are operations of one invocation, not components anyone - * composes with. Freezing the inputs, installing a constrained Agent frame, telling an + * ``, ``, ``, `` and + * `` are the phases of one invocation, not components anyone composes + * with. Freezing the inputs, installing a constrained Agent frame, telling an * operator which phase is running, answering about a draft and admitting the * approved bytes are each meaningless outside the workflow that orders them — * and each carries authority the enclosing document does not have. * So they resolve only while canonical core is expanding these exact bytes: * not from the caller's root, not from the Prompt the caller projected, not from * a sibling ``, and not from anything middleware can answer. + * + * `` is not among them. What a document may write here is a public + * question with a public answer, and canonical core owns both — so `Plan.md` + * writes the same component any other document writes, and the catalog the Agent + * is shown is the one an operator can print. */ import { createHash } from "node:crypto"; @@ -206,8 +211,6 @@ export interface PlanComponentAssembly { observeAuthorship?(observation: PlanAuthorshipObservation): Operation; /** Who answers the review question. */ installElicitation(): Operation; - /** The run profile's rendered vocabulary, as the first Agent turn receives it. */ - catalog(): Operation; /** * How this host decides whether one candidate is structurally a program. * @@ -223,37 +226,30 @@ export interface PlanComponentAssembly { const INPUTS_RETURNS = { type: "object", properties: { - syntax: { type: "string" }, session: { type: "string" }, surface: { type: "string" }, durable: { type: "boolean" }, authoredSession: { type: "string" }, }, - required: ["syntax", "session", "surface", "durable"], + required: ["session", "surface", "durable"], additionalProperties: false, }; /** - * What the frozen inputs are given: the caller's optional session name, the - * prompt this invocation is about, and the syntax `` already froze. + * What the frozen inputs are given: the caller's optional session name and the + * prompt this invocation is about. * - * Only the prompt's digest is retained here. The syntax has its own durable - * record, so each protocol can be read and reconciled independently. + * Only the prompt's digest is retained here. The vocabulary the Agent is shown + * is public ``'s, with a durable record of its own, so each protocol + * can be read and reconciled independently. */ const INPUTS_PROPS = { type: "object", properties: { session: { type: "string", minLength: 1 }, instruction: { type: "string" }, - syntax: { type: "string" }, }, - required: ["instruction", "syntax"], - additionalProperties: false, -}; - -const NO_PROPS = { - type: "object", - properties: {}, + required: ["instruction"], additionalProperties: false, }; @@ -354,7 +350,6 @@ export function* planComponentDeclaration( // formatting it as Markdown would publish bytes nobody approved. exact: true, privates: [ - planSyntax(assembly), planInputs(assembly), planAuthorship(assembly), planProgress(assembly), @@ -411,12 +406,6 @@ export function* planComponentDescription(): Operation[] = [ - { - name: "Syntax", - props: NO_PROPS, - returns: { type: "string" }, - forms: ["self-closing"], - }, { name: "PlanInputs", props: INPUTS_PROPS, @@ -443,53 +432,18 @@ function* uninvocable(): Operation { ); } -/** Read the author-visible run vocabulary supplied by this Plan's host. */ -function planSyntax(assembly: PlanComponentAssembly): IdentityComponent { - return { - name: "Syntax", - origin: `${PLAN_ORIGIN}#Syntax`, - forms: ["self-closing"], - props: NO_PROPS, - returns: { type: "string" }, - factory: (claim: IdentityClaimant) => - function* Syntax( - _props: Record, - invocation: ComponentInvocation, - ): Operation { - const id = yield* claim(invocation); - const frozen = yield* durablePlanOperation(`plan:syntax:${id}`, function* () { - return { syntax: yield* assembly.catalog() }; - }); - const retained = readSyntax(frozen); - if (retained === undefined) { - throw new StaleInputError(UNREADABLE_SYNTAX); - } - return retained; - }, - }; -} - -function readSyntax(value: Json): string | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return undefined; - } - const { syntax } = value; - if (Object.keys(value).length !== 1 || typeof syntax !== "string") { - return undefined; - } - return syntax; -} - -const UNREADABLE_SYNTAX = - "the retained Plan syntax cannot be read as syntax, so no Plan source was produced."; - /** * Freeze this invocation's authorship inputs, and retain them. * - * The syntax is the retained observation `` supplied. The instruction - * identity here makes a continuation answerable at all: comparing it refuses a - * Plan asked under another prompt before a directory, a provider, a turn, a - * review or an admission exists. + * The vocabulary the Agent is shown is not here. `` is a public + * component canonical core owns, it retains its own observation, and `Plan.md` + * binds it directly — so the catalog and the question are two records that can + * be read and reconciled independently rather than one that has to be read + * whole. + * + * The instruction identity here makes a continuation answerable at all: + * comparing it refuses a Plan asked under another prompt before a directory, a + * provider, a turn, a review or an admission exists. * * The session placement is derived here too, from the durable identity canonical * execution minted for this exact expansion — which is what makes two `` @@ -517,7 +471,6 @@ function planInputs(assembly: PlanComponentAssembly): IdentityComponent { const session = placementFor(assembly, id, authored); const instruction = sourceDigest(String(props.instruction)); - const syntax = String(props.syntax); const frozen = yield* durablePlanOperation(`plan:inputs:${id}`, function* () { return { instruction }; @@ -533,7 +486,6 @@ function planInputs(assembly: PlanComponentAssembly): IdentityComponent { } return { - syntax, session, surface: assembly.surface, durable: durability(assembly, authored), diff --git a/packages/cli/src/plan.ts b/packages/cli/src/plan.ts index 8b0259f2..1e4791e1 100644 --- a/packages/cli/src/plan.ts +++ b/packages/cli/src/plan.ts @@ -203,13 +203,6 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio verbose: command.verbose, host, installElicitation: deps.installElicitation, - // Built when `` asks, which is what lets an authored phase - // announce the preparation before it happens. It is still sealed: the - // catalog the agent is shown is the one this command renders, and no prop - // on the thin adapter could supply another. - *catalog() { - return renderSyntaxMarkdown(yield* deps.catalog(command.include)); - }, validate, }); @@ -222,6 +215,13 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio declaration, stream, progress: deps.progress, + // The vocabulary this authorship describes is the `run` profile's, not + // this execution's: a Plan is a program a later `xmd run` executes, so the + // catalog the Agent must be shown is the one that run will have. Stated at + // the execution boundary and captured before any installed code — no prop + // on the thin adapter, and nothing the Component projects, could supply + // another. + catalog: () => deps.catalog(command.include), }); } catch (error) { console.error(describeError(error)); diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 39915006..a601696a 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -8,8 +8,11 @@ * arrange. The second is rendering, and both renderers take the catalog as a * value — neither performs discovery, and neither parses the other's output. * - * JSON is the canonical, lossless projection. Markdown is written for a person - * and says so when a schema is richer than a table can summarize honestly. + * JSON is the canonical, lossless projection and belongs to this command. + * Markdown belongs to core, because a document that writes `` is shown + * the same catalog in the same words: two renderings that agreed only by hand + * would be one release away from telling an operator and an agent different + * things about one profile. */ import { planComponentDescription } from "./plan-component.ts"; @@ -20,21 +23,16 @@ import { agentIdentityComponents, inspectSyntax, registerComponents, + renderSyntaxMarkdown, } from "@executablemd/core"; -import type { - CompleteComponentSyntaxEntry, - ComponentOrigin, - Json, - OriginOnlyComponentSyntaxEntry, - PropsSchema, - StructuralSyntaxEntry, - SyntaxCatalog, -} from "@executablemd/core"; +import type { SyntaxCatalog } from "@executablemd/core"; import { TESTING_REGISTRATIONS } from "@executablemd/testing"; import { WEB_REGISTRATIONS } from "@executablemd/web"; import { VERBOSE_REGISTRATION } from "./verbose-component.ts"; import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; +export { renderSyntaxMarkdown }; + /** * The catalog for the production `run` profile, in the contextual working * directory. @@ -100,229 +98,3 @@ export function* useRunProfileRegistry(): Operation { export function renderSyntaxJson(catalog: SyntaxCatalog): string { return `${JSON.stringify(catalog, null, 2)}\n`; } - -/** The three category kinds, taken from the catalog rather than restated. */ -type CategoryKind = SyntaxCatalog["categories"][number]["kind"]; - -const HEADINGS: Record = { - structural: "## Built-in structural syntax", - "built-in": "## Built-in components", - "user-provided": "## User-provided components", -}; - -const EMPTY: Record = { - structural: "No structural constructs are reserved.", - "built-in": "No components are registered in this profile.", - "user-provided": "No components were found in the configured includes.", -}; - -export function renderSyntaxMarkdown(catalog: SyntaxCatalog): string { - const sections = catalog.categories.map((category) => { - const blocks: string[] = [HEADINGS[category.kind]]; - if (category.entries.length === 0) { - blocks.push(EMPTY[category.kind]); - return blocks.join("\n\n"); - } - for (const entry of category.entries) { - blocks.push(...renderEntry(entry)); - } - return blocks.join("\n\n"); - }); - return `${sections.join("\n\n")}\n`; -} - -function renderEntry( - entry: StructuralSyntaxEntry | CompleteComponentSyntaxEntry | OriginOnlyComponentSyntaxEntry, -): string[] { - if (entry.kind === "structural") { - return renderStructural(entry); - } - if (entry.inspectability === "origin-only") { - return renderOriginOnly(entry); - } - return renderComponent(entry); -} - -function heading(name: string): string { - return `### \`<${name}>\``; -} - -function renderStructural(entry: StructuralSyntaxEntry): string[] { - const blocks = [heading(entry.name), entry.description]; - blocks.push("**Syntax:**", fence("md", entry.syntax.join("\n"))); - blocks.push(...prose(entry)); - return blocks; -} - -function renderOriginOnly(entry: OriginOnlyComponentSyntaxEntry): string[] { - return [ - heading(entry.name), - "This component is a repository TypeScript module. Its contract lives on the module's " + - "exports, and reading it would import the module and run its top-level code — which " + - "describing an environment must not do. The module was not imported, so its props, " + - "captures, forms and return are unavailable here.", - `**Origin:** ${describeOrigin(entry.origin)}`, - ]; -} - -function renderComponent(entry: CompleteComponentSyntaxEntry): string[] { - const blocks = [heading(entry.name)]; - if (entry.description !== undefined) { - blocks.push(entry.description); - } - blocks.push(`**Forms:** ${entry.forms.map((form) => invocation(entry.name, form)).join(", ")}`); - blocks.push(...renderProps(entry.props)); - if (entry.captures.length > 0) { - blocks.push( - `**Captures:** ${entry.captures.map(code).join(", ")} — evaluated by the component ` + - "itself, so these props are deliberately absent from the schema above.", - ); - } - blocks.push(...prose(entry)); - blocks.push(...renderReturns(entry)); - blocks.push(`**Origin:** ${describeOrigin(entry.origin)}`); - return blocks; -} - -function prose(entry: { as?: string; context?: string }): string[] { - const blocks: string[] = []; - if (entry.as !== undefined) { - blocks.push(`**\`as\`:** ${entry.as}`); - } - if (entry.context !== undefined) { - blocks.push(`**Body context:** ${entry.context}`); - } - return blocks; -} - -function invocation(name: string, form: "self-closing" | "paired"): string { - return code(form === "self-closing" ? `<${name} />` : `<${name}>…`); -} - -function renderReturns(entry: CompleteComponentSyntaxEntry): string[] { - if (entry.returnMode === "text") { - return [ - "**Returns:** text — the markdown this component renders.", - fence("json", stringify(entry.returns)), - ]; - } - return [ - "**Returns:** a value — it renders nothing, and `as` binds what it returns.", - fence("json", stringify(entry.returns)), - ]; -} - -/** - * The props table, and the schema it summarizes. - * - * The table is the readable half and the schema is the authoritative one. A - * table cannot carry `default`, `enum`, a combinator, a reference or a root - * constraint, so the schema is printed beside it rather than reduced into it, - * and a property the table cannot name a type for is labelled honestly instead - * of being given an invented one. - */ -function renderProps(props: PropsSchema): string[] { - const rows = propertyRows(props); - const blocks = ["#### Props"]; - if (rows.length === 0) { - blocks.push("This component declares no individual props."); - } else { - blocks.push( - ["| Prop | Type | Required | Description |", "| --- | --- | --- | --- |", ...rows].join("\n"), - ); - } - blocks.push(fence("json", stringify(props))); - return blocks; -} - -function propertyRows(props: PropsSchema): string[] { - const properties = props.properties; - if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { - return []; - } - const required = new Set( - Array.isArray(props.required) - ? props.required.filter((name): name is string => typeof name === "string") - : [], - ); - const rows: string[] = []; - for (const [name, schema] of Object.entries(properties)) { - // Every cell is escaped on the way in, the prop name included: a schema - // property may be spelled with anything, and one pipe in a name would - // shift every column after it. - rows.push( - row([ - code(name), - summarizeType(schema), - required.has(name) ? "yes" : "no", - describeProp(schema), - ]), - ); - } - return rows; -} - -function row(cells: readonly string[]): string { - return `| ${cells.map(cell).join(" | ")} |`; -} - -/** - * The type column, or an honest refusal to reduce one. - * - * A plain `type` — one name or a union of them — summarizes faithfully. - * Anything else is a schema whose constraints do not fit a word, so the column - * says JSON Schema and the reader goes to the block below it. - */ -function summarizeType(schema: Json): string { - if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { - return "JSON Schema"; - } - const type = schema.type; - if (typeof type === "string") { - return code(type); - } - if (Array.isArray(type) && type.every((member) => typeof member === "string")) { - // Unescaped: `row()` escapes every cell once, and escaping here as well - // would put a backslash in front of the backslash. - return type.map(code).join(" | "); - } - return "JSON Schema"; -} - -function describeProp(schema: Json): string { - if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { - return ""; - } - const description = schema.description; - return typeof description === "string" ? description : ""; -} - -function describeOrigin(origin: ComponentOrigin): string { - if (origin.kind === "repository") { - return code(origin.path); - } - if (origin.kind === "registered") { - return `${code(origin.origin)} (${origin.reserved ? "reserved registration" : "registered default"})`; - } - if (origin.kind === "declared-markdown") { - return `${code(origin.origin)} (declared Markdown)`; - } - return `structural syntax (${code(origin.construct)})`; -} - -function code(text: string): string { - return `\`${text}\``; -} - -/** A table cell: pipes escaped, and line breaks folded so the row stays a row. */ -function cell(text: string): string { - return text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); -} - -function fence(language: string, body: string): string { - return ["```" + language, body, "```"].join("\n"); -} - -function stringify(value: Json): string { - return JSON.stringify(value, null, 2); -} diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index 9959ef7f..0e43c323 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -803,8 +803,9 @@ describe( "No Plan was returned. Nothing was output.", ); // Refused before the catalog, a directory, a provider, a turn or a - // review existed. The catalog is built by private ``, and this - // refusal happens before the command document starts at all. + // review existed. The catalog is observed by public `` inside + // the command document, and this refusal happens before that document + // starts at all. expect(untouched(harness)).toEqual({ catalogs: 0, runtimes: 0, diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 7e1746fa..8d953424 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -33,13 +33,23 @@ import { retainedSource, useNormalizedOutput, } from "@executablemd/core"; -import type { DocumentValidation, ElicitationRequest, Json } from "@executablemd/core"; +import type { + DocumentValidation, + ElicitationRequest, + Json, + SyntaxCatalog, +} from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; import { PLAN_COMMAND_IDENTITY } from "../src/authorship-profile.ts"; import type { PlanSurface } from "../src/plan-component.ts"; -import { AGENT, planDeclarationHarness, useWorkingDirectory } from "./support/plan-harness.ts"; +import { + AGENT, + CASE_CATALOG, + planDeclarationHarness, + useWorkingDirectory, +} from "./support/plan-harness.ts"; import type { ScriptedReview } from "./support/plan-harness.ts"; import type { ScriptedTurn } from "./support/fake-acp.ts"; @@ -132,10 +142,13 @@ function* runDocument(options: RunOptions = {}): Operation { session: SESSION, explicitSession: true, ...(options.verbose === undefined ? {} : { verbose: options.verbose }), + // The catalog is stated at the execution boundary now, so this records + // *when* the public `` occurrence observed it — which is what an + // ordering case about the authored Preparing phase is asking. // deno-lint-ignore require-yield - *catalog() { + *catalog(): Operation { events.push("catalog"); - return "## Built-in components\n\n### ``\n"; + return CASE_CATALOG; }, *validate(): Operation { const answer = validations.length > 1 ? validations.shift() : validations[0]; @@ -174,6 +187,7 @@ function* runDocument(options: RunOptions = {}): Operation { { components: agentIdentityComponents(), declarations: [harness.declaration], + catalog: harness.catalog, }, ], ); @@ -486,7 +500,13 @@ describe("the packaged plan command document", () => { secretDetection: true, props: { request: REQUEST, session: SESSION }, }, - [{ components: agentIdentityComponents(), declarations: [installed] }], + [ + { + components: agentIdentityComponents(), + declarations: [installed], + catalog: harness.catalog, + }, + ], ); // deno-lint-ignore require-yield yield* forEach(function* (chunk: string) { diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 7de8d12d..9b101f87 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -30,7 +30,7 @@ import { retainedSource, useNormalizedOutput, } from "@executablemd/core"; -import type { Json } from "@executablemd/core"; +import type { Json, SyntaxCatalog } from "@executablemd/core"; import { validateDocument } from "@executablemd/core"; import { executeInstalled, sourceDigest } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -50,6 +50,7 @@ import { } from "../src/plan-component.ts"; import type { StructuralValidation } from "../src/plan-component.ts"; import { syntaxCatalog } from "../src/syntax.ts"; +import { PLAN_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; const ROOT = "document.md"; @@ -153,6 +154,10 @@ function* runDocument(options: { { components: agentIdentityComponents(), declarations: [harness.declaration], + // Where the profile a document observes is settled now: the + // `` the packaged Plan writes is canonical core's public + // component, and what it answers with is this execution's. + catalog: harness.catalog, }, ], ); @@ -228,6 +233,70 @@ describe("Tier PC — in an ordinary document", () => { }); }); + it("PC1b: the packaged Plan writes the public , and its catalog reaches the first turn once", function* () { + yield* useWorkingDirectory(function* () { + const source = yield* readPackagedDocument(PLAN_DOCUMENT); + // The public component, written the way any document writes it. + expect(source).toContain(''); + // And nothing declares a second one: the private closure is the five + // phases, and `Syntax` is not among them. + const declaration = yield* planComponentDescription(); + expect((declaration.privates ?? []).map((component) => component.name)).toEqual([ + "PlanInputs", + "PlanAuthorship", + "PlanProgress", + "CheckDraft", + "AdmitPlan", + ]); + + const run = yield* runDocument({ + source: ['Write a program.', "", "got: {approved}", ""].join( + "\n", + ), + reply: PLAN, + }); + + expect(run.failure).toBe(undefined); + // The retained catalog is what the first turn was built from, and it is + // there exactly once — a second copy would mean the Component both bound + // it and emitted it. + const first = run.harness.fake.prompts[0] ?? ""; + expect(first).toContain("### ``"); + expect(first.split("### ``").length - 1).toBe(1); + expect(run.harness.catalogCalls).toBe(1); + }); + }); + + it("PC1c: a catalog observation that fails reaches no session, turn, review or Plan", function* () { + yield* useWorkingDirectory(function* (dir) { + const harness = yield* planDeclarationHarness({ + surface: "component", + authorshipRoot: `${dir}-profile`, + // deno-lint-ignore require-yield + *catalog(): Operation { + throw new Error("the profile could not be described"); + }, + }); + const run = yield* runDocument({ + source: ['Write a program.', "", "got: {approved}", ""].join( + "\n", + ), + reply: PLAN, + reviews: [], + harness, + }); + + expect(run.failure).toContain("the profile could not be described"); + // Nothing downstream of the observation happened: no turn was taken, no + // review was asked, no draft was checked, and no Plan was bound. + expect(run.harness.fake.prompts).toEqual([]); + expect(run.harness.reviews).toEqual([]); + expect(run.harness.checked).toEqual([]); + expect(run.output).not.toContain("# Say hello"); + expect(run.emitted).not.toContain("# Say hello"); + }); + }); + it("PC2: the Prompt is not emitted, and no Plan source is printed", function* () { yield* useWorkingDirectory(function* () { const run = yield* runDocument({ @@ -309,7 +378,6 @@ describe("Tier PC — in an ordinary document", () => { it("PC6: the private capabilities resolve nowhere a document can write", function* () { yield* useWorkingDirectory(function* () { for (const name of [ - "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", @@ -322,6 +390,15 @@ describe("Tier PC — in an ordinary document", () => { }); expect(run.failure).toContain(`Cannot resolve component: ${name}`); } + // The positive control for the same document shape: `` is not one + // of Plan's private names, it is the public component canonical core owns, + // so the identical invocation resolves and binds the catalog. + const open = yield* runDocument({ + source: ['', "{x}", ""].join("\n"), + reviews: [], + }); + expect(open.failure).toBeUndefined(); + expect(open.output).toContain("### ``"); }); }); @@ -346,7 +423,6 @@ describe("Tier PC — in an ordinary document", () => { for (const category of catalog.categories) { const names = category.entries.map((entry) => entry.name); for (const priv of [ - "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", @@ -356,6 +432,10 @@ describe("Tier PC — in an ordinary document", () => { expect(names).not.toContain(priv); } } + // `` is not one of them any more. It is public, canonical core + // owns it, and the catalog an author reads says so — which is what stops + // the absence check above passing because the whole set went missing. + expect(builtIn.map((entry) => entry.name)).toContain("Syntax"); }); }); @@ -1023,29 +1103,33 @@ describe("Tier PC — in an ordinary document", () => { }); }); - it("PC27: the private syntax snapshot is closed and hostile records produce nothing", function* () { + it("PC27: the Plan's catalog observation is core's closed record, and a hostile one produces nothing", function* () { yield* useWorkingDirectory(function* () { const approved = yield* approvedRun(); - const syntax = (yield* approved.readAll()).find( - (event) => event.type === "yield" && event.description.name.startsWith("plan:syntax:"), + // The record is canonical core's, not Plan's: the packaged Component + // writes the same public `` any document writes, so what a + // continuation restores is a `syntax_catalog` observation rather than + // anything this host retained. + const observation = (yield* approved.readAll()).find( + (event) => event.type === "yield" && event.description.type === "syntax_catalog", ); - expect(syntax?.type).toBe("yield"); - if (syntax?.type !== "yield" || syntax.result.status !== "ok") { - throw new Error("the approved run retained no syntax snapshot"); + expect(observation?.type).toBe("yield"); + if (observation?.type !== "yield" || observation.result.status !== "ok") { + throw new Error("the approved run retained no catalog observation"); } - const value = Object(syntax.result.value); - expect(Object.keys(value)).toEqual(["syntax"]); - expect(typeof value.syntax).toBe("string"); + const value = Object(observation.result.value); + expect(Object.keys(value)).toEqual(["catalog"]); + expect(typeof value.catalog).toBe("string"); const cases: [string, (value: Json) => Json][] = [ ["the member is missing", () => ({})], ["an unknown member was added", (record) => ({ ...Object(record), extra: true })], - ["the member has the wrong type", () => ({ syntax: 7 })], + ["the member has the wrong type", () => ({ catalog: 7 })], ]; for (const [, replace] of cases) { - const run = yield* continued(yield* tampered(approved, "plan:syntax:", replace)); - expect(run.failure).toContain("retained Plan syntax cannot be read as syntax"); + const run = yield* continued(yield* tampered(approved, "syntax_catalog:", replace)); + expect(run.failure).toContain("retained catalog is not a catalog"); expect(run.output).not.toContain("got:"); expect(run.output).not.toContain("# Say hello"); expect(run.harness.fake.prompts).toEqual([]); diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index f3922f24..789bfda8 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -29,7 +29,7 @@ import { planComponentDeclaration } from "../../src/plan-component.ts"; import type { PlanSurface, StructuralValidation } from "../../src/plan-component.ts"; import { planAgentContext } from "../../src/authorship-profile.ts"; import type { AuthorshipStack } from "../../src/agent-stack.ts"; -import type { DeclaredMarkdownComponent } from "@executablemd/core/host"; +import type { CatalogContribution, DeclaredMarkdownComponent } from "@executablemd/core/host"; import type { PlanDependencies } from "../../src/plan.ts"; import { createFakeAcp, makeRegistry, makeStore } from "./fake-acp.ts"; import type { FakeAcp, FakeStore } from "./fake-acp.ts"; @@ -291,6 +291,16 @@ export interface PlanDeclarationHarness { reviews: ElicitationRequest[]; /** The declaration to attach to an execution. */ declaration: DeclaredMarkdownComponent; + /** + * The catalog this case's execution describes. + * + * Attached to the execution rather than to the declaration, because that is + * where the profile a document observes is now settled: `` is public, + * canonical core owns it, and what it answers with is the execution's own. + */ + catalog: CatalogContribution; + /** How many times that contribution was asked. */ + catalogCalls: number; /** Review answers, taken in order. Running out is a test defect, not a case. */ script(review: ScriptedReview): void; } @@ -308,16 +318,14 @@ export function* planDeclarationHarness(options: { surface: PlanSurface; authorshipRoot: string; includes?: readonly string[]; - /** The catalog the first turn is built from. */ - syntax?: string; /** - * Build the catalog, in place of answering with {@link syntax}. + * The catalog this case's execution describes, in place of the default below. * - * A case that needs to know *when* the catalog was built supplies this, which - * is the only way to tell an authored phase that precedes the preparation from - * one that follows it. + * A case that needs to know *when* the catalog was observed supplies this, + * which is the only way to tell an authored phase that precedes the + * observation from one that follows it. */ - catalog?: () => Operation; + catalog?: () => Operation; /** * How this case answers the one structural question the Component asks. * @@ -386,12 +394,6 @@ export function* planDeclarationHarness(options: { { at: "min" }, ); }, - *catalog() { - if (options.catalog !== undefined) { - return yield* options.catalog(); - } - return options.syntax ?? "## Built-in components\n\n### ``\n"; - }, // The deterministic seam standing where production's answer goes, recording // every candidate it was asked about — the draft check's and the // admission's alike, which is every time these bytes are decided on. @@ -402,13 +404,53 @@ export function* planDeclarationHarness(options: { }, }); - return { + const harness: PlanDeclarationHarness = { fake, checked, reviews, declaration, + catalogCalls: 0, + *catalog(): Operation { + harness.catalogCalls += 1; + if (options.catalog !== undefined) { + return yield* options.catalog(); + } + return CASE_CATALOG; + }, script(review) { answers.push(review); }, }; + return harness; } + +/** + * The vocabulary a case's execution describes, unless it states another. + * + * One entry, so the rendered catalog carries a marker a prompt assertion can + * look for without depending on the whole run profile being assembled. + */ +export const CASE_CATALOG: SyntaxCatalog = { + version: 1, + categories: [ + { kind: "structural", entries: [] }, + { + kind: "built-in", + entries: [ + { + kind: "component", + name: "File", + origin: { kind: "registered", origin: "@executablemd/core", reserved: false }, + sourceKind: "registered", + inspectability: "complete", + forms: ["self-closing", "paired"], + props: { type: "object", properties: {}, additionalProperties: false }, + captures: [], + returnMode: "text", + returns: { type: "string" }, + }, + ], + }, + { kind: "user-provided", entries: [] }, + ], +}; diff --git a/packages/cli/tests/support/run-markdown-tier.ts b/packages/cli/tests/support/run-markdown-tier.ts index 123f847f..b973aaa3 100644 --- a/packages/cli/tests/support/run-markdown-tier.ts +++ b/packages/cli/tests/support/run-markdown-tier.ts @@ -31,7 +31,6 @@ import { testHarnessInstallation, useTesting } from "@executablemd/testing"; import type { TestResult } from "@executablemd/testing"; import { cliBase, cliCommand, cliRuntime } from "@executablemd/test-support/launch"; import { planComponentDeclaration } from "../../src/plan-component.ts"; -import { renderSyntaxMarkdown, syntaxCatalog } from "../../src/syntax.ts"; import { testingExecutionHost } from "../../src/testing-host.ts"; import { useBunService } from "../../src/bun-service.ts"; import { useDenoService } from "../../src/deno-service.ts"; @@ -105,9 +104,6 @@ export function runMarkdownTier(document: string): Operation { ? {} : { observeAuthorship: request.observeAuthorship }), installElicitation: request.installElicitation, - *catalog(): Operation { - return renderSyntaxMarkdown(yield* syntaxCatalog(["components", "."])); - }, }), // This harness runs Markdown tiers, not repository work: a child that // asked for a checkout is told there is no provider. diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index d47fff3f..cc5f5877 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -175,6 +175,36 @@ describe("Tier SX — the run profile the command describes", () => { expect(catalog.categories[2].entries).toEqual([]); }); + it("SX1b: describes once, as the component canonical core owns", function* () { + const catalog = yield* syntaxCatalog([]); + const everywhere = catalog.categories.flatMap((category) => + category.entries.filter((entry) => entry.name === "Syntax"), + ); + // Once, and in the built-in category: a name a document cannot take back is + // not user-provided, and two entries would mean two tiers answered. + expect(everywhere).toHaveLength(1); + expect(names(catalog.categories[1].entries)).toContain("Syntax"); + + const [entry] = everywhere; + if (entry === undefined || entry.kind !== "component" || entry.inspectability !== "complete") { + throw new Error("the catalog describes without a contract"); + } + expect(entry.origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }); + expect(entry.forms).toEqual(["self-closing"]); + expect(entry.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); + expect(entry.captures).toEqual([]); + expect(entry.returnMode).toBe("text"); + expect(entry.description).toBe( + "Output available components and control flow constructs. `` renders the " + + "current catalog.", + ); + expect(entry.as).toBe("Optional. Captures the rendered catalog instead of emitting it."); + }); + it("ORC1: names all thirteen repository-composition components, with contracts", function* () { const catalog = yield* syntaxCatalog([]); const entries = catalog.categories[1].entries; @@ -395,6 +425,39 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("SX8b: an ordinary run observes its own profile and includes, and agrees with the command", function* () { + yield* useWorkspace( + { + "components/Local.md": "---\ndescription: the first description.\n---\n\nlocal\n", + "catalog.md": "\n", + }, + function* (cwd) { + // The same site, asked two ways: the command that describes an + // environment, and a document running in it. A run that derived its + // catalog from anything but its own captured inputs would disagree. + const described = yield* runCli(["syntax"], { cwd }).expect(); + const observed = yield* runCli(["run", "catalog.md"], { cwd }).expect(); + expect(observed.stdout.trim()).toBe(described.stdout.trim()); + expect(observed.stdout).toContain("### ``"); + expect(observed.stdout).toContain("the first description."); + // And the run really is describing the run profile it has, not a + // reduced one: `` is declared to every ordinary run. + expect(observed.stdout).toContain("### ``"); + + // A fresh occurrence sees a moved environment. Nothing is cached across + // executions, and the catalog is the working tree's rather than the + // build's. + yield* writeTextFile( + join(cwd, "components/Local.md"), + "---\ndescription: the second description.\n---\n\nlocal\n", + ); + const again = yield* runCli(["run", "catalog.md"], { cwd }).expect(); + expect(again.stdout).toContain("the second description."); + expect(again.stdout).not.toContain("the first description."); + }, + ); + }); + it("SX9: reports an unusable include on stderr, exits 1, and prints no catalog", function* () { yield* useWorkspace({ components: "not a directory\n" }, function* (cwd) { const { code, stdout, stderr } = yield* runCli(["syntax", "--include", "components"], { diff --git a/packages/cli/tests/testing-execution-host.test.ts b/packages/cli/tests/testing-execution-host.test.ts index e56f8e3f..ce2993a1 100644 --- a/packages/cli/tests/testing-execution-host.test.ts +++ b/packages/cli/tests/testing-execution-host.test.ts @@ -322,6 +322,46 @@ describe("nested execution under the production run host", () => { expect(bare.stdout + bare.stderr).toContain("1 of 1 tests failed"); }); + /** + * A child is a root, so the vocabulary it observes is its own. + * + * `` describes the site it is written at, and a child's site is the + * child run profile with the child's includes — not the outer test's. The + * positive control is the same document run as the outer root: the name the + * child cannot see is one the outer include path really does supply, so the + * absence below is isolation rather than a component nobody has. + */ + it("gives a nested run child its own catalog rather than the outer root's", function* () { + const project = yield* useProject({ + "elsewhere/Greeting.md": doc("hello"), + "catalog.md": doc(""), + "README.md": doc( + '', + '\\n"} as="child">', + '', + "", + '', + "`/} />", + "", + "", + ), + }); + + // The child runs with no include of its own, so the outer command's + // `--include elsewhere` does not put `` in the child's catalog. + const nested = yield* runCli(["test", "README.md"], { cwd: project }).join(); + expect(nested.code).toBe(0); + + // The control: with that include configured, an ordinary root at the same + // site does observe the name, so the child's catalog above was narrower + // rather than empty. + const outer = yield* runCli(["run", "catalog.md", "--include", "elsewhere"], { + cwd: project, + }).join(); + expect(outer.code).toBe(0); + expect(outer.stdout).toContain("### ``"); + }); + it("refuses outside a canonical ", function* () { const project = yield* useProject({ "child.md": doc("child"), @@ -658,10 +698,6 @@ describe("deterministic dependencies declared for a nested run", () => { ? {} : { observeAuthorship: request.observeAuthorship }), installElicitation: request.installElicitation, - // deno-lint-ignore require-yield - *catalog(): Operation { - return ""; - }, }), *observePlanAuthorship(observation): Operation { observed.resolve(observation); diff --git a/packages/core/host.ts b/packages/core/host.ts index 3d1dabd0..15c40ec1 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -68,6 +68,11 @@ */ export { executeInstalled } from "./src/execute.ts"; export type { ExecutionInstallation, JournalAdmission } from "./src/execute.ts"; +/** + * The catalog a host's profile describes, when it is not the one the execution + * would derive from its own captured inputs — see `src/syntax-observation.ts`. + */ +export type { CatalogContribution } from "./src/syntax-observation.ts"; export type { DurablePreparation } from "./src/document-request.ts"; /** diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d31eb6d1..699879b3 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -213,6 +213,13 @@ export type { SyntaxCatalog, } from "./src/inspect.ts"; export { ComponentIncludeError } from "./src/components/candidates.ts"; +/** + * The catalog as Markdown, so `xmd syntax` and canonical `` print the + * same bytes for the same site rather than two renderings that agree by hand. + */ +export { renderSyntaxMarkdown } from "./src/syntax-markdown.ts"; +export { PROTECTED_COMPONENT_NAMES, ProtectedComponentError } from "./src/components/protected.ts"; +export { SYNTAX_COMPONENT } from "./src/components/Syntax.ts"; // Document validation — one supplied document read as authored program // structure, with nothing in it executed. export { diff --git a/packages/core/src/components/Syntax.ts b/packages/core/src/components/Syntax.ts new file mode 100644 index 00000000..8125aab3 --- /dev/null +++ b/packages/core/src/components/Syntax.ts @@ -0,0 +1,178 @@ +/** + * `` — what a document may write, written into the document. + * + * An author asking "which components do I have here?" and an agent being told + * what to write are the same question, and `xmd syntax` already answers it from + * outside. This is the same answer from inside: the catalog for the site the + * element was written at, as the Markdown that command prints. + * + * ## Why canonical core owns it + * + * The catalog describes the vocabulary an execution actually has. A repository + * `Syntax.md`, a bundled `Syntax`, a registration, an import handler or a second + * loaded copy answering for the name would each describe a vocabulary the run + * does not have — to whoever is reading, and to whichever agent is being told + * what to write next. So the name is claimed by the canonical protected tier, + * ahead of every host and author tier, and the definition canonical core + * selected is what runs. + * + * Protection is about the *answer*, not about power. The component receives one + * operation that observes catalog text and nothing else: no definitions, no + * import witness, no invocation capability, no policy table, no provider and no + * registration handle. A catalog naming a component is not permission to run it. + * + * ## What one occurrence does + * + * It claims the occurrence identity this execution minted, observes once, and + * retains exactly what it observed. A continuation reads that record and hands + * the same catalog back without consulting the filesystem, the registry, the + * bundle, the host or the lexical observation again — so an agent resuming + * authorship is shown the vocabulary the run actually showed it, not one + * rebuilt from a tree that has moved since. + */ + +import { createDurableOperation, StaleInputError } from "@executablemd/durable-streams"; +import type { Json as DurableJson, Workflow } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; + +import { getExpansion } from "../expansion.ts"; +import { ComponentInvocationError, invocationForm } from "../invocation-identity.ts"; +import type { + ComponentInvocation, + IdentityClaimant, + ProtectedBody, +} from "../invocation-identity.ts"; +import { sourceDescription } from "../source-position.ts"; +import type { CatalogObservation } from "../syntax-observation.ts"; +import type { ProtectedComponent } from "./protected.ts"; +import { CORE_ORIGIN } from "./registry.ts"; +import { documented } from "./documentation.ts"; +import type { Json, PropsSchema, SourcePosition } from "../types.ts"; + +/** The public name canonical core claims for the catalog component. */ +export const SYNTAX_COMPONENT = "Syntax"; + +/** The durable effect one occurrence records. */ +const SYNTAX_CATALOG = "syntax_catalog"; + +/** + * No props at all, closed. + * + * The site decides what the catalog says; there is nothing for an author to + * select. A prop written here is refused before the body runs, which is what + * keeps a spelling nobody supports from quietly rendering the whole catalog + * anyway. + */ +export const props: PropsSchema = { + type: "object", + properties: {}, + additionalProperties: false, +}; + +const PAIRED_REFUSAL = + " renders the current catalog and reads no content, so it is written self-closing."; + +const UNISSUED_REFUSAL = + " is invoked by canonical core; this is not an invocation the engine issued."; + +const NO_OBSERVATION_REFUSAL = + " has no catalog to observe here: this expansion carries none, so nothing " + + "established what a document may write at this site."; + +const UNREADABLE_RECORD = + "the retained catalog is not a catalog this version can read, so no catalog was " + + "produced."; + +/** + * The declaration canonical core selects for ``. + * + * Self-closing only, no props, and no `returns` — which is what makes it a text + * component: the bare form emits the catalog through the current presentation + * middleware, and `as` captures the same text through the engine's ordinary + * capture and emits nothing. + */ +export const SYNTAX_PROTECTED: ProtectedComponent = { + name: SYNTAX_COMPONENT, + origin: CORE_ORIGIN, + props, + forms: ["self-closing"], + ...documented({ + description: + "Output available components and control flow constructs. `` renders the " + + "current catalog.", + as: "Optional. Captures the rendered catalog instead of emitting it.", + context: null, + }), + build: (claim: IdentityClaimant) => syntax(claim), +}; + +function syntax(claim: IdentityClaimant): ProtectedBody { + return function* observeCatalog( + _props: Record, + invocation: ComponentInvocation, + observation: CatalogObservation | undefined, + ): Operation { + // Read off the issuance the engine holds rather than off a method the + // caller could have written, and answered before anything is claimed or + // observed: a paired spelling is a document asking for something this + // component does not have, not a catalog to go and build. + const form = invocationForm(invocation); + if (form === undefined) { + throw new ComponentInvocationError(UNISSUED_REFUSAL); + } + if (form === "paired") { + throw new ComponentInvocationError(PAIRED_REFUSAL); + } + const id = yield* claim(invocation); + if (observation === undefined) { + throw new Error(NO_OBSERVATION_REFUSAL); + } + const expansion = yield* getExpansion(); + return yield* persistCatalog(id, expansion.position, () => observation.observe()); + }; +} + +function* persistCatalog( + id: string, + position: Readonly | undefined, + live: () => Operation, +): Workflow { + const stored = yield createDurableOperation( + { + type: SYNTAX_CATALOG, + name: `${SYNTAX_CATALOG}:${id}`, + ...sourceDescription(position), + }, + function* (): Operation { + return { catalog: yield* live() }; + }, + ); + const catalog = readCatalog(stored); + if (catalog === undefined) { + // A record this version cannot read is the journal no longer describing + // this run, not a component that failed: it travels as the stale input it + // is, rather than becoming an error segment a printing boundary could turn + // into text and carry on past. + throw new StaleInputError(UNREADABLE_RECORD); + } + return catalog; +} + +/** + * The catalog a record holds, read as a closed protocol. + * + * Exactly one member, a string. A record missing it, carrying a member this + * version does not know, or holding one of the wrong type is a record this + * version cannot read — not one to fill a default in for, because every default + * here is a guess about what an earlier run actually showed somebody. + */ +function readCatalog(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const catalog = Reflect.get(value, "catalog"); + if (Object.keys(value).length !== 1 || typeof catalog !== "string") { + return undefined; + } + return catalog; +} diff --git a/packages/core/src/components/bundle.ts b/packages/core/src/components/bundle.ts index 51f47700..14b5bbb6 100644 --- a/packages/core/src/components/bundle.ts +++ b/packages/core/src/components/bundle.ts @@ -37,6 +37,7 @@ */ import type { ImportRefusal, ImportTier } from "./import-authority.ts"; +import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; import { CORE_COMPONENT_NAMES } from "./registry.ts"; import { isComponentName } from "./registration.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; @@ -114,6 +115,11 @@ export class WorkflowImportAuthority implements ImportTier { return this.#components.get(name); } + /** Every name this bundle supplies, for a catalog describing the run. */ + names(): Iterable { + return this.#components.keys(); + } + claims(name: string): boolean { return this.#components.has(name); } @@ -175,6 +181,11 @@ export function installedBundle( "owns rather than a component.", ); } + if (PROTECTED_COMPONENT_NAMES.has(name)) { + throw new WorkflowBundleError( + `a workflow component bundle ${protectedNameRefusal(name, "declare")}.`, + ); + } if (CORE_COMPONENT_NAMES.has(name)) { throw new WorkflowBundleError( `a workflow component bundle declared "${name}", which is a component the engine ` + diff --git a/packages/core/src/components/declared-markdown.ts b/packages/core/src/components/declared-markdown.ts index 12a20255..ff03b71d 100644 --- a/packages/core/src/components/declared-markdown.ts +++ b/packages/core/src/components/declared-markdown.ts @@ -46,6 +46,7 @@ import { parseMarkdownDefinition } from "../definition.ts"; import { formsRefusal } from "../invocation-identity.ts"; import type { IdentityComponent } from "../invocation-identity.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; +import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; import { CanonicalImports, retain } from "./import-authority.ts"; import type { ImportedDefinition, ImportRefusal, ImportTier } from "./import-authority.ts"; import { admitDeclaration, isComponentName } from "./registration.ts"; @@ -178,6 +179,9 @@ export function* admitDeclaredMarkdown( "engine owns rather than a component.", ); } + if (PROTECTED_COMPONENT_NAMES.has(name)) { + throw refuse(`a host ${protectedNameRefusal(name, "declare as Markdown")}.`); + } if (origin.length === 0) { throw refuse( `the declared Markdown component "${name}" needs an origin naming where it came from.`, diff --git a/packages/core/src/components/import-authority.ts b/packages/core/src/components/import-authority.ts index b747d600..144e1bf2 100644 --- a/packages/core/src/components/import-authority.ts +++ b/packages/core/src/components/import-authority.ts @@ -19,9 +19,14 @@ */ import type { ComponentDefinition, FunctionComponentDefinition } from "../types.ts"; -import type { FormSelections, InvocationIdentities } from "../invocation-identity.ts"; +import type { + FormSelections, + InvocationIdentities, + ProtectedBodies, +} from "../invocation-identity.ts"; import type { DeclaredImports, PrivateClosure } from "./declared-markdown.ts"; import type { ExactSource } from "../output/exact-source.ts"; +import type { CatalogObservation } from "../syntax-observation.ts"; /** A definition an import may answer with. */ export type ImportedDefinition = ComponentDefinition | FunctionComponentDefinition; @@ -101,6 +106,31 @@ export interface ExpansionAuthority { * a component or middleware can name reaches it. */ readonly forms?: FormSelections; + /** + * What a document may write at the site being expanded. + * + * The execution builds one at its root from the selection inputs it captured, + * and hands it here by value like everything else on this object — not through + * a Context, because a context resolves by name and a name is not a secret, so + * a document could build one and answer for the vocabulary it is shown. + * + * It is lexical. A trusted canonical evaluation boundary that has already + * admitted the exact vocabulary a subtree may write replaces this member for + * that subtree, and leaving the subtree restores the enclosing one. Nothing + * else changes it: an ordinary component's body, the content a caller + * projected and an imported definition each carry what the site carried. + */ + readonly catalog?: CatalogObservation; + /** + * The bodies this execution will enter for the components canonical core + * protects. + * + * Held by the execution and handed here by value, like the identity domains + * beside it. It is what makes a protected implementation reachable at all: an + * implementation another loaded copy built is in that copy's table, and one + * kept past this execution's teardown reaches a table that is gone. + */ + readonly protectedBodies?: ProtectedBodies; } /** Why an answer is not the one canonical execution produced for this name. */ diff --git a/packages/core/src/components/protected.ts b/packages/core/src/components/protected.ts new file mode 100644 index 00000000..529e0123 --- /dev/null +++ b/packages/core/src/components/protected.ts @@ -0,0 +1,133 @@ +/** + * The names canonical core answers for, ahead of every other tier. + * + * Resolution already had a protected tier — a reserved registration, or exact + * Markdown a host declared — but both of those are *a host's* claim, made by + * whoever assembled the run. This tier is the engine's own, and it sits above + * them: a protected name means the same thing in every execution, whichever host + * built it, whichever package registered what, and whatever a repository holds. + * + * There is one component in it. `` describes the vocabulary of the + * site it is written at, and a description of a run's vocabulary that anything + * in the run could answer for is a description of nothing — the value of the + * answer is exactly that nobody but core produced it. + * + * ## What protection is, and is not + * + * Protection settles *which definition runs*. A repository `Syntax.md`, a + * bundled `Syntax`, an ordinary or reserved registration, a declared Markdown + * component and a definition from a second loaded copy of core can none of them + * win selection here. `Component.importComponent` middleware composes around the + * import exactly as it composes around any other: it may observe it, delegate it + * and refuse it by throwing, and what it cannot do is answer one — the answer is + * verified at the call site against core's own retained copy, and core's copy is + * what is invoked. + * + * Protection is not authority to do anything. A protected implementation is + * handed the lexical observation for its site and nothing else: no component + * definitions, no import witness, no invocation capability, no policy table, no + * provider and no registration handle. + * + * ## Why it is not a registration + * + * A registration is an answer a registry gives for a name, and a registry is + * something a nested scope layers over, a host installs into, and a handler can + * keep a record from and hand back somewhere else. A name that must mean one + * thing cannot be decided by any of that, so the tier is the resolver's own + * table and the implementation reaches the call site through the execution that + * built it. + */ + +import { SYNTAX_PROTECTED } from "./Syntax.ts"; +import type { ImportRefusal, ImportTier } from "./import-authority.ts"; +import type { ComponentDocumentation } from "./documentation.ts"; +import type { ProtectedDeclaration } from "../invocation-identity.ts"; +import type { ComponentOrigin } from "../types.ts"; + +/** + * One component canonical core claims the name of. + * + * The same contract an identity component declares, minus the choice of origin: + * a protected component is core's, so it reports core's origin. Its body is + * built once per execution, with the claimant that execution minted, and the + * implementation the execution wraps it in is the only thing that name resolves + * to there. + */ +export interface ProtectedComponent extends ProtectedDeclaration, ComponentDocumentation {} + +/** Every name the engine itself claims. */ +export const PROTECTED_COMPONENTS: readonly ProtectedComponent[] = Object.freeze([ + SYNTAX_PROTECTED, +]); + +const BY_NAME: ReadonlyMap = new Map( + PROTECTED_COMPONENTS.map((component) => [component.name, component]), +); + +/** The names above, as a set, for the admissions that must refuse them. */ +export const PROTECTED_COMPONENT_NAMES: ReadonlySet = new Set(BY_NAME.keys()); + +/** What canonical core answers for this name, or nothing when it answers none. */ +export function protectedComponent(name: string): ProtectedComponent | undefined { + return BY_NAME.get(name); +} + +/** + * The origin a protected component reports. + * + * `reserved` is the catalog's word for a name a document cannot take back, which + * is what this tier makes true of it. No new origin kind: a reader learns where + * the component came from and that nothing shadows it, from the two fields the + * schema already has. + */ +export function protectedOrigin( + component: ProtectedComponent, +): Extract { + return { kind: "registered", origin: component.origin, reserved: true }; +} + +/** A protected name a host, a bundle or a registration tried to claim. */ +export class ProtectedComponentError extends Error { + override name = "ProtectedComponentError"; +} + +/** What a name that canonical core owns refuses a second claim with. */ +export function protectedNameRefusal(name: string, claim: string): string { + return ( + `cannot ${claim} "${name}": canonical core owns that name, so what it means is the same in ` + + "every execution and nothing else answers for it" + ); +} + +/** The fixed diagnostic each verification failure produces. */ +const REFUSED: Record = { + unissued: + "Component.importComponent middleware answered an import of a component canonical core " + + "owns with a definition canonical execution did not produce. A handler may observe, " + + "delegate or refuse the import; only canonical execution answers one.", + "another-name": + "Component.importComponent middleware answered an import of a component canonical core " + + "owns with the definition canonical execution produced for another component.", + changed: + "Component.importComponent middleware changed the definition canonical execution produced " + + "for a component canonical core owns before it was invoked.", +}; + +/** + * The tier a protected import is verified through. + * + * It closes the names it claims and nothing else, exactly as a declaration does: + * claiming `Syntax` says nothing about what any other name in the execution may + * resolve to, and every other import stays the open one it has always been. + */ +export class ProtectedImports implements ImportTier { + claims(name: string): boolean { + return PROTECTED_COMPONENT_NAMES.has(name); + } + + readonly closesExecution = false; + + refuse(refusal: ImportRefusal): Error { + return new ProtectedComponentError(REFUSED[refusal]); + } +} diff --git a/packages/core/src/components/registration.ts b/packages/core/src/components/registration.ts index 0f29b787..9670f2cb 100644 --- a/packages/core/src/components/registration.ts +++ b/packages/core/src/components/registration.ts @@ -17,6 +17,7 @@ import type { Context, Operation } from "effection"; import { Component } from "../component-api.ts"; import { updateOwn } from "../scope-local.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; +import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; import { formsRefusal } from "../invocation-identity.ts"; import { documentationOf } from "./documentation.ts"; import type { ComponentDocumentation } from "./documentation.ts"; @@ -135,6 +136,9 @@ function assertUsableName(name: string): void { `cannot register "${name}": it is structural syntax the engine owns, not a component`, ); } + if (PROTECTED_COMPONENT_NAMES.has(name)) { + throw new ComponentRegistrationError(protectedNameRefusal(name, "register")); + } if (!isComponentName(name)) { throw new ComponentRegistrationError( `cannot register "${name}": a component name is capitalized, and each ` + diff --git a/packages/core/src/components/select.ts b/packages/core/src/components/select.ts index e6970712..1a80a372 100644 --- a/packages/core/src/components/select.ts +++ b/packages/core/src/components/select.ts @@ -5,13 +5,16 @@ * disagree about which tier won: * * 1. structural syntax the engine owns; - * 2. a host claiming the name — a reserved registration, or exact Markdown this + * 2. a component canonical core protects. The engine's own claim rather than a + * host's, so a protected name means the same thing in every execution, + * whichever host assembled it and whatever a repository holds; + * 3. a host claiming the name — a reserved registration, or exact Markdown this * environment declares. Two claims on one name are refused where they are * installed, so this tier never has to choose between them; - * 3. the workflow component bundle this execution is closed over; - * 4. a repository-local file; - * 5. a registered default, including core's own components; - * 6. nothing, which is the unresolved printed error. + * 4. the workflow component bundle this execution is closed over; + * 5. a repository-local file; + * 6. a registered default, including core's own components; + * 7. nothing, which is the unresolved printed error. * * The bundle tier exists only while a trusted host installed one, and a * workflow execution searches no repository directories at all — so what a @@ -29,6 +32,7 @@ import type { Operation } from "effection"; import type { WorkflowImportAuthority } from "./bundle.ts"; import type { DeclaredMarkdownCatalog } from "./declared-markdown.ts"; import { mergeRegistry } from "./registration.ts"; +import { protectedComponent, protectedOrigin } from "./protected.ts"; import { CORE_REGISTRY } from "./registry.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; import type { ComponentOrigin, ComponentRegistry, ComponentSelection } from "../types.ts"; @@ -129,6 +133,14 @@ export function* selectComponent( return { kind: "structural", construct: name }; } + // Above every host and author tier, and unconditional: the table is core's + // own, so no option a caller passes — or leaves out — can put a repository + // file, a bundle member or a registration in front of it. + const owned = protectedComponent(name); + if (owned !== undefined) { + return { kind: "protected", component: owned, origin: protectedOrigin(owned) }; + } + if (entry?.reserved) { return { kind: "registered", diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 5ec432ff..15350db9 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -879,6 +879,25 @@ class ValidationState { return; } + if (selected.kind === "protected") { + // Checked from the declaration alone: what the name means is core's, and + // validation neither runs an execution nor calls a factory to learn it. + draft.origin = selected.origin; + yield* this.#checkContract( + segment, + context, + draft, + { + props: selected.component.props, + captures: selected.component.captures ?? [], + forms: selected.component.forms ?? BOTH_FORMS, + hasReturns: selected.component.returns !== undefined, + }, + capture, + ); + return; + } + if (selected.kind === "registered") { draft.origin = selected.origin; yield* this.#checkContract( diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 7e516dfd..6961b503 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -139,6 +139,9 @@ import { import type { IdentityComponent } from "./invocation-identity.ts"; import { ExecutionImports } from "./components/import-authority.ts"; import type { ExpansionAuthority, ImportTier } from "./components/import-authority.ts"; +import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; +import { rootCatalogObservation } from "./syntax-observation.ts"; +import type { CatalogContribution } from "./syntax-observation.ts"; import type { WorkflowComponentBundle, WorkflowImportAuthority } from "./components/bundle.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; import { readRootSource, rootSourcePath } from "./root-source.ts"; @@ -247,7 +250,16 @@ type DurableSelection = * the same way the live run did — by being inside the same declaration's body * when it asks. */ - | { kind: "declared-private"; origin: string }; + | { kind: "declared-private"; origin: string } + /** + * A component canonical core protects. + * + * Nothing but the kind is retained. The name is already the record's identity, + * and what the name resolves to is core's own table rather than anything a + * host, a registry or a checkout supplies — so a replay reconstructs it the + * way the live run did, by asking the copy of core that is running. + */ + | { kind: "protected" }; /** * What a recorded import decided, read as a closed protocol. @@ -282,6 +294,13 @@ function readDurableSelection(value: unknown): DurableSelection | undefined { return { kind: "declared-private", origin }; } + if (kind === "protected") { + if (members !== 1) { + return undefined; + } + return { kind: "protected" }; + } + if (kind === "declared-markdown") { const origin = record["origin"]; const digest = record["digest"]; @@ -402,6 +421,7 @@ function* durableImportComponent( position: Readonly | undefined, bundle: WorkflowImportAuthority | undefined, declared: DeclaredImports | undefined, + guarded: ReadonlyMap, ): Workflow { // Taken before the durable operation and outside it, because the offer is // canonical core's own and a replay has to reach this the same way the live @@ -455,6 +475,12 @@ function* durableImportComponent( }); switch (selected.kind) { + case "protected": + // Nothing about the answer is recorded: what this name means is + // core's own, so a replay asks the copy of core that is running + // rather than restoring an origin a registry would have to still + // hold. + return { kind: "protected" }; case "repository": return { kind: "repository", @@ -519,6 +545,21 @@ function* durableImportComponent( throw documentTargetError(failure); } + if (selection.kind === "protected") { + // The implementation this execution built from the claimant it minted for + // this component. Not a registry lookup and not a file: a protected name is + // canonical core's answer, and an execution that built none for it has no + // protected implementation to run. + const own = guarded.get(name); + if (own === undefined) { + throw new Error( + `Component ${name} was recorded as a component canonical core owns, and this execution ` + + "built no implementation for it.", + ); + } + return own; + } + if (selection.kind === "registered") { // The function was never journaled. Find the implementation the recorded // origin names in the registry this run has; refusing when it is gone is @@ -2110,6 +2151,7 @@ function* executeDocument( bundles: readonly WorkflowComponentBundle[] = [], identityComponents: readonly IdentityComponent[] = [], declarations: readonly DeclaredMarkdownComponent[] = [], + catalogs: readonly CatalogContribution[] = [], ): Operation { const { stream, @@ -2226,6 +2268,10 @@ function* executeDocument( const identity = installIdentities( identityComponents, admittedDeclarations.flatMap((declaration) => [...declaration.privates]), + // Core's own, minted the same way and registered nowhere. Every + // execution has them, whatever its host declared, which is what makes a + // protected name mean one thing everywhere. + PROTECTED_COMPONENTS, ); yield* registerComponents(identity.registrations); identity.activate(); @@ -2263,16 +2309,36 @@ function* executeDocument( if (declaredImports !== undefined) { tiers.push(declaredImports); } - const imports = tiers.length === 0 ? undefined : new ExecutionImports(tiers); + // Last, because a tier that claims a name answers for it and the earlier + // ones claim names of their own; a bundled execution still words every + // other refusal exactly as it always did. Present in every execution, + // because a protected name is closed in every execution. + tiers.push(new ProtectedImports()); + const imports = new ExecutionImports(tiers); const authority: ExpansionAuthority = { - ...(imports === undefined ? {} : { imports }), + imports, ...(declaredImports === undefined ? {} : { declared: declaredImports }), identities: identity.identities, + protectedBodies: identity.protectedBodies, forms, // Created here, held here, and reclaimed with this execution. Nothing a // document, a component, middleware or a separately loaded copy can // name reaches this object. exact: createExactSource(), + // Built from what this execution captured before any installation, + // middleware or document code ran, and asked only when an occurrence + // observes: a run whose document never writes `` enumerates + // nothing. + catalog: rootCatalogObservation( + { + includes, + registry: startingRegistry, + components: identityComponents, + declarations, + ...(bundle === undefined ? {} : { workflow: bundle }), + }, + catalogs[0], + ), }; // Install the document's runtime Component providers before durableRun @@ -2292,6 +2358,7 @@ function* executeDocument( position, bundle, declaredImports, + identity.protected, ); // Canonical selection, recorded where it is made. This is the only // thing that puts an invocation in one of this execution's identity @@ -2546,6 +2613,21 @@ export interface ExecutionInstallation { * component that can name a durable operation after its invocation. */ readonly components?: readonly IdentityComponent[]; + /** + * The catalog this host's profile describes, when its profile is not the one + * the execution itself would derive. + * + * Captured by value alongside the admissions, before any installation runs, + * for the reason the rest are: what a document observes is settled before + * anything can observe or replace it. Omitted is the ordinary case — canonical + * core derives the catalog from the selection inputs this execution captured, + * which is what makes an ordinary run's observation the run's own. + * + * `xmd plan` states one, because the Plan being written is a program a later + * `xmd run` executes: the vocabulary the agent must be shown is that profile's + * rather than the authorship execution's. One execution accepts one. + */ + readonly catalog?: CatalogContribution; install?(): Operation; } @@ -2937,6 +3019,23 @@ function* invoke( ), ); + // Read once and frozen with the rest, and before any installation runs: which + // profile a document observes is settled before anything can observe it. Two + // are refused rather than ordered — a catalog chosen by installation order + // would make what an agent is told to write depend on assembly order. + const catalogs = Object.freeze( + installations.flatMap((installation) => { + const catalog = installation.catalog; + return catalog === undefined ? [] : [catalog]; + }), + ); + if (catalogs.length > 1) { + throw new Error( + "two installations stated the catalog this execution describes. One execution describes " + + "one vocabulary, so which profile a document observes is never a question of order.", + ); + } + for (const installation of installations) { if (installation.install) { yield* installation.install(); @@ -2975,6 +3074,7 @@ function* invoke( bundles, identityComponents, declarations, + catalogs, ); } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index ffe05f10..5a4e2512 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -3221,6 +3221,19 @@ function* expandFunctionComponent( if (TestHarnessComponentDefinition.own(definition.fn)) { return yield* definition.fn.invoke(validatedProps, binding); } + // A component canonical core protects reads one lexical fact — the + // catalog for this site — and the fact changes as expansion descends, + // so it cannot be closed over when the implementation is built. It is + // delivered here instead, by the copy of core performing the + // expansion, from the authority it is already holding. + const guarded = authority?.protectedBodies?.body(definition.fn); + if (guarded !== undefined) { + try { + return yield* guarded(validatedProps, issued.invocation, authority?.catalog); + } finally { + issued.close(); + } + } // Ended in the same breath the body is: an issuance a wrapper kept // from a finished element authorizes nothing when it is routed here. try { diff --git a/packages/core/src/inspect.ts b/packages/core/src/inspect.ts index f2dca639..a8a88247 100644 --- a/packages/core/src/inspect.ts +++ b/packages/core/src/inspect.ts @@ -3,6 +3,7 @@ import { readTextFile } from "@executablemd/runtime"; import type { ComponentOrigin, + ComponentRegistry, ComponentSelection, InvocationForm, PropsSchema, @@ -21,6 +22,8 @@ import { declaredRegistry } from "./components/declared-registry.ts"; import { admitDeclaredMarkdown, declaredCatalog } from "./components/declared-markdown.ts"; import type { DeclaredMarkdownComponent } from "./components/declared-markdown.ts"; import { repositoryCandidateNames } from "./components/candidates.ts"; +import { PROTECTED_COMPONENT_NAMES } from "./components/protected.ts"; +import type { WorkflowImportAuthority } from "./components/bundle.ts"; import { documentationOf } from "./components/documentation.ts"; import type { ComponentDocumentation } from "./components/documentation.ts"; import { STRUCTURAL_DECLARATIONS } from "./structural.ts"; @@ -205,6 +208,16 @@ export function* inspectComponent(options: InspectComponentOptions): Operation { const includes = options.includes ?? DEFAULT_INCLUDES; + const bundled = options.workflow; const declared = options.components ?? []; // The whole declaration set is admitted before anything is built from it, on // exactly the terms ordinary execution admits it on. A set an execution would @@ -411,7 +447,10 @@ export function* inspectSyntax(options: InspectSyntaxOptions): Operation { + if (selected.kind === "protected") { + const { component, origin } = selected; + // Described from the declaration alone. The factory is never called: it + // takes an execution's claimant, and describing an environment mints no + // execution and no claimant to give it. + return complete(name, origin, "registered", { + forms: component.forms ?? BOTH_FORMS, + props: component.props, + captures: component.captures ?? [], + returns: component.returns, + documentation: documentationOf(component), + }); + } + if (selected.kind === "registered") { const { definition, origin } = selected; if (origin.kind === "structural") { @@ -540,10 +602,24 @@ function* componentEntry( }); } + if (selected.kind === "workflow") { + // The pinned bytes, already in hand: the bundle was read from the + // definition's own commit before this execution existed, so describing one + // reads no file, imports no module and runs nothing. It is the run author's + // own Markdown, reported at the canonical path it holds inside that commit. + const definition = yield* parseMarkdownDefinition(name, selected.path, selected.content); + return complete(name, { kind: "repository", path: selected.path }, "markdown", { + forms: BOTH_FORMS, + props: definition.props, + captures: [], + returns: definition.returns, + documentation: documentationOf(definition.meta), + }); + } + if (selected.kind !== "repository") { - // A bundled or unresolved name describes no environment a document writes - // in: inspection installs no bundle, and a name nothing supplies is exactly - // the absence the catalog reports by leaving it out. + // An unresolved name is exactly the absence the catalog reports by leaving + // it out. return undefined; } diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index 259e71e6..8f16eb9e 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -52,6 +52,7 @@ import type { Operation, Scope } from "effection"; import { printErrors, printsErrors } from "./component-failures.ts"; import { documentationOf } from "./components/documentation.ts"; import type { ComponentDocumentation } from "./components/documentation.ts"; +import type { CatalogObservation } from "./syntax-observation.ts"; import type { FunctionComponent, FunctionComponentDefinition, @@ -151,6 +152,96 @@ export interface IdentityDomain { readonly component: string; } +/** + * What the body of a component canonical core protects receives. + * + * A protected component is selected by canonical core ahead of every host or + * author tier, so its name is settled before anything a document, a package or + * middleware can reach. What is *not* settled by the name is the lexical fact it + * reads — the catalog observation in scope where the element was written — and + * that changes as expansion descends, so it cannot be closed over when the + * implementation is built. It is delivered here instead, by the copy of core + * performing the expansion, from the authority that copy is already holding. + * + * The observation is `undefined` where an expansion carries none. The body + * refuses rather than inventing a catalog: a component that answered without one + * would be describing an environment nothing established. + */ +export type ProtectedBody = ( + props: Record, + invocation: ComponentInvocation, + observation: CatalogObservation | undefined, +) => Operation; + +/** + * The bodies one execution will enter, keyed by the exact function it built. + * + * Execution-owned and reclaimed with the execution, like the domains beside it, + * and handed to core's own expansion by value. Two things follow. An + * implementation another loaded copy built — which is ordinary, because a + * component can be loaded from disk beside its own copy — is in that copy's + * table rather than this one, so it has no body here. And an implementation kept + * past this execution's teardown reaches nothing, because the table went with + * the execution. + */ +export interface ProtectedBodies { + /** The body canonical expansion may enter for this exact implementation. */ + body(fn: unknown): ProtectedBody | undefined; +} + +interface ProtectedInstallation extends ProtectedBodies { + /** + * Build one implementation and keep its real body here. + * + * What comes back is inert: it is what registration-shaped machinery compares + * by identity and what a form selection records, and calling it through any + * route but canonical expansion refuses rather than observing anything. + */ + implementation( + name: string, + build: (claim: IdentityClaimant) => ProtectedBody, + claim: IdentityClaimant, + ): FunctionComponent; +} + +function createProtectedBodies(): ProtectedInstallation { + const bodies = new WeakMap(); + return { + implementation(name, build, claim): FunctionComponent { + // deno-lint-ignore require-yield + function* unreachable(): Operation { + throw new ComponentInvocationError( + `<${name} /> is invoked by canonical core, so an implementation reached any other way ` + + "observes nothing", + ); + } + bodies.set(unreachable, build(claim)); + return unreachable; + }, + body(fn): ProtectedBody | undefined { + return typeof fn === "function" ? bodies.get(fn) : undefined; + }, + }; +} + +/** + * A component canonical core claims the name of, as this module builds one. + * + * The same declaration a host's identity component makes, except that the + * factory hands over a body rather than an implementation: what a protected + * component may be invoked through is the execution's to decide, not the + * declaration's. + */ +export interface ProtectedDeclaration { + readonly name: string; + readonly props: PropsSchema; + readonly returns?: ReturnsSchema; + readonly captures?: readonly string[]; + readonly forms?: readonly InvocationForm[]; + readonly origin: string; + build(claim: IdentityClaimant): ProtectedBody; +} + /** * What one execution knows about the components it gave identity to. * @@ -706,7 +797,9 @@ function mintDomain(component: string): Minted { * this one function so the two cannot come to disagree about what a host may * declare. */ -export function assertDistinctIdentityNames(components: readonly IdentityComponent[]): void { +export function assertDistinctIdentityNames( + components: readonly { readonly name: string }[], +): void { const seen = new Set(); for (const component of components) { if (seen.has(component.name)) { @@ -798,6 +891,17 @@ export interface IdentityInstallation { * the declaration that carries it. */ readonly privates: ReadonlyMap; + /** + * The implementations canonical core's own protected tier resolves, by name. + * + * Minted exactly like the rest — one domain, one claimant, revoked with the + * execution — and then registered nowhere, because a registry is precisely + * what must not decide a protected name. Canonical resolution answers for + * these from its own table (`components/protected.ts`). + */ + readonly protected: ReadonlyMap; + /** The bodies canonical expansion may enter for those implementations. */ + readonly protectedBodies: ProtectedBodies; /** Called once the registrations have been validated and committed. */ activate(): void; } @@ -814,17 +918,45 @@ export interface IdentityInstallation { export function installIdentities( components: readonly IdentityComponent[], privateComponents: readonly IdentityComponent[] = [], + protectedComponents: readonly ProtectedDeclaration[] = [], ): IdentityInstallation { // Before any factory: a set nobody can register is a set nobody may build // implementations from either, and a duplicate that reached a factory would - // have minted a claimant for a domain that is about to be discarded. The two + // have minted a claimant for a domain that is about to be discarded. The three // sets are checked together because they mint into one table of domains, so a - // private name that shadowed a registered one would take its domain. - assertDistinctIdentityNames([...components, ...privateComponents]); + // private name that shadowed a registered one would take its domain — and a + // host that declared a protected name reaches this only after admission has + // already refused it, so a duplicate here is core's own mistake. + assertDistinctIdentityNames([...components, ...privateComponents, ...protectedComponents]); const minted = new Map(); const registrations: IdentityRegistration[] = []; const privates = new Map(); + const guarded = new Map(); + const protectedBodies = createProtectedBodies(); + for (const component of protectedComponents) { + const domain = mintDomain(component.name); + minted.set(component.name, domain); + const implementation = protectedBodies.implementation( + component.name, + component.build, + domain.claim, + ); + domain.implementation = implementation; + // Not marked private: a protected implementation is resolved by canonical + // core's own tier under its public name, so refusing it wherever an answer + // becomes something the engine invokes would refuse the component itself. + guarded.set(component.name, { + kind: "function", + name: component.name, + props: component.props, + ...(component.returns === undefined ? {} : { returns: component.returns }), + ...(component.captures === undefined ? {} : { captures: component.captures }), + ...(component.forms === undefined ? {} : { forms: component.forms }), + ...documentationOf(component), + fn: implementation, + }); + } for (const component of privateComponents) { const domain = mintDomain(component.name); minted.set(component.name, domain); @@ -915,6 +1047,8 @@ export function installIdentities( }, registrations, privates, + protected: guarded, + protectedBodies, activate: () => { for (const domain of minted.values()) { domain.activate(); diff --git a/packages/core/src/syntax-markdown.ts b/packages/core/src/syntax-markdown.ts new file mode 100644 index 00000000..928aae20 --- /dev/null +++ b/packages/core/src/syntax-markdown.ts @@ -0,0 +1,251 @@ +/** + * The catalog as Markdown a person reads. + * + * One renderer, in core, because two things print it: `xmd syntax`, which + * describes an environment without running it, and canonical ``, which + * hands the same text to a document that is running. Rendering here is what + * makes those two answers the same bytes for the same site — a renderer the CLI + * owned could only be reached by the CLI, and a component in core would have + * needed a second one. + * + * It takes the catalog as a value. It discovers nothing, reads no filesystem, + * resolves no name and parses no other projection's output, so what it prints is + * exactly what construction decided. + * + * Markdown is written for a person: where a schema carries more than a table can + * summarize honestly, the table says so and the schema is printed beside it. + */ + +import type { + CompleteComponentSyntaxEntry, + OriginOnlyComponentSyntaxEntry, + StructuralSyntaxEntry, + SyntaxCatalog, +} from "./inspect.ts"; +import type { ComponentOrigin, Json, PropsSchema } from "./types.ts"; + +/** The three category kinds, taken from the catalog rather than restated. */ +type CategoryKind = SyntaxCatalog["categories"][number]["kind"]; + +const HEADINGS: Record = { + structural: "## Built-in structural syntax", + "built-in": "## Built-in components", + "user-provided": "## User-provided components", +}; + +const EMPTY: Record = { + structural: "No structural constructs are reserved.", + "built-in": "No components are registered in this profile.", + "user-provided": "No components were found in the configured includes.", +}; + +export function renderSyntaxMarkdown(catalog: SyntaxCatalog): string { + const sections = catalog.categories.map((category) => { + const blocks: string[] = [HEADINGS[category.kind]]; + if (category.entries.length === 0) { + blocks.push(EMPTY[category.kind]); + return blocks.join("\n\n"); + } + for (const entry of category.entries) { + blocks.push(...renderEntry(entry)); + } + return blocks.join("\n\n"); + }); + return `${sections.join("\n\n")}\n`; +} + +function renderEntry( + entry: StructuralSyntaxEntry | CompleteComponentSyntaxEntry | OriginOnlyComponentSyntaxEntry, +): string[] { + if (entry.kind === "structural") { + return renderStructural(entry); + } + if (entry.inspectability === "origin-only") { + return renderOriginOnly(entry); + } + return renderComponent(entry); +} + +function heading(name: string): string { + return `### \`<${name}>\``; +} + +function renderStructural(entry: StructuralSyntaxEntry): string[] { + const blocks = [heading(entry.name), entry.description]; + blocks.push("**Syntax:**", fence("md", entry.syntax.join("\n"))); + blocks.push(...prose(entry)); + return blocks; +} + +function renderOriginOnly(entry: OriginOnlyComponentSyntaxEntry): string[] { + return [ + heading(entry.name), + "This component is a repository TypeScript module. Its contract lives on the module's " + + "exports, and reading it would import the module and run its top-level code — which " + + "describing an environment must not do. The module was not imported, so its props, " + + "captures, forms and return are unavailable here.", + `**Origin:** ${describeOrigin(entry.origin)}`, + ]; +} + +function renderComponent(entry: CompleteComponentSyntaxEntry): string[] { + const blocks = [heading(entry.name)]; + if (entry.description !== undefined) { + blocks.push(entry.description); + } + blocks.push(`**Forms:** ${entry.forms.map((form) => invocation(entry.name, form)).join(", ")}`); + blocks.push(...renderProps(entry.props)); + if (entry.captures.length > 0) { + blocks.push( + `**Captures:** ${entry.captures.map(code).join(", ")} — evaluated by the component ` + + "itself, so these props are deliberately absent from the schema above.", + ); + } + blocks.push(...prose(entry)); + blocks.push(...renderReturns(entry)); + blocks.push(`**Origin:** ${describeOrigin(entry.origin)}`); + return blocks; +} + +function prose(entry: { as?: string; context?: string }): string[] { + const blocks: string[] = []; + if (entry.as !== undefined) { + blocks.push(`**\`as\`:** ${entry.as}`); + } + if (entry.context !== undefined) { + blocks.push(`**Body context:** ${entry.context}`); + } + return blocks; +} + +function invocation(name: string, form: "self-closing" | "paired"): string { + return code(form === "self-closing" ? `<${name} />` : `<${name}>…`); +} + +function renderReturns(entry: CompleteComponentSyntaxEntry): string[] { + if (entry.returnMode === "text") { + return [ + "**Returns:** text — the markdown this component renders.", + fence("json", stringify(entry.returns)), + ]; + } + return [ + "**Returns:** a value — it renders nothing, and `as` binds what it returns.", + fence("json", stringify(entry.returns)), + ]; +} + +/** + * The props table, and the schema it summarizes. + * + * The table is the readable half and the schema is the authoritative one. A + * table cannot carry `default`, `enum`, a combinator, a reference or a root + * constraint, so the schema is printed beside it rather than reduced into it, + * and a property the table cannot name a type for is labelled honestly instead + * of being given an invented one. + */ +function renderProps(props: PropsSchema): string[] { + const rows = propertyRows(props); + const blocks = ["#### Props"]; + if (rows.length === 0) { + blocks.push("This component declares no individual props."); + } else { + blocks.push( + ["| Prop | Type | Required | Description |", "| --- | --- | --- | --- |", ...rows].join("\n"), + ); + } + blocks.push(fence("json", stringify(props))); + return blocks; +} + +function propertyRows(props: PropsSchema): string[] { + const properties = props.properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { + return []; + } + const required = new Set( + Array.isArray(props.required) + ? props.required.filter((name): name is string => typeof name === "string") + : [], + ); + const rows: string[] = []; + for (const [name, schema] of Object.entries(properties)) { + // Every cell is escaped on the way in, the prop name included: a schema + // property may be spelled with anything, and one pipe in a name would + // shift every column after it. + rows.push( + row([ + code(name), + summarizeType(schema), + required.has(name) ? "yes" : "no", + describeProp(schema), + ]), + ); + } + return rows; +} + +function row(cells: readonly string[]): string { + return `| ${cells.map(cell).join(" | ")} |`; +} + +/** + * The type column, or an honest refusal to reduce one. + * + * A plain `type` — one name or a union of them — summarizes faithfully. + * Anything else is a schema whose constraints do not fit a word, so the column + * says JSON Schema and the reader goes to the block below it. + */ +function summarizeType(schema: Json): string { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return "JSON Schema"; + } + const type = schema.type; + if (typeof type === "string") { + return code(type); + } + if (Array.isArray(type) && type.every((member) => typeof member === "string")) { + // Unescaped: `row()` escapes every cell once, and escaping here as well + // would put a backslash in front of the backslash. + return type.map(code).join(" | "); + } + return "JSON Schema"; +} + +function describeProp(schema: Json): string { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return ""; + } + const description = schema.description; + return typeof description === "string" ? description : ""; +} + +function describeOrigin(origin: ComponentOrigin): string { + if (origin.kind === "repository") { + return code(origin.path); + } + if (origin.kind === "registered") { + return `${code(origin.origin)} (${origin.reserved ? "reserved registration" : "registered default"})`; + } + if (origin.kind === "declared-markdown") { + return `${code(origin.origin)} (declared Markdown)`; + } + return `structural syntax (${code(origin.construct)})`; +} + +function code(text: string): string { + return `\`${text}\``; +} + +/** A table cell: pipes escaped, and line breaks folded so the row stays a row. */ +function cell(text: string): string { + return text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); +} + +function fence(language: string, body: string): string { + return ["```" + language, body, "```"].join("\n"); +} + +function stringify(value: Json): string { + return JSON.stringify(value, null, 2); +} diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts new file mode 100644 index 00000000..9536e016 --- /dev/null +++ b/packages/core/src/syntax-observation.ts @@ -0,0 +1,127 @@ +/** + * What a document may write here, as one thing an execution carries. + * + * `xmd syntax` answers that question for an environment nobody is running. + * Canonical `` answers it for the site an element was actually written + * at, and the two have to be the same answer — a catalog an agent is shown and + * a catalog an operator prints describe one vocabulary or they describe none. + * + * So there is one construction and one renderer, and this module is where an + * execution keeps its own use of them. The observation is built from the + * selection inputs the execution captured before any installation, middleware or + * document code ran: the includes it resolves against, the registry it started + * with, the identity components and exact Markdown its host declared, and the + * component bundle it is closed over when it has one. Nothing is read from a + * context, a registry answer, or anything a document can reach. + * + * A trusted host may state the catalog for its own profile instead. `xmd plan` + * does: a Plan is written to be run by `xmd run`, so the vocabulary the agent + * must be shown is the run profile's rather than the authorship execution's. + * That contribution is captured with the rest of the installation, before any + * installed code exists, and one execution accepts one — two are refused rather + * than ordered, because ordering them would make which profile a document + * observes depend on installation order. + * + * The observation carries no authority at all. It answers with text. Seeing a + * component named in a catalog neither registers it, resolves it, nor authorizes + * it: what a name means is still `selectComponent()`'s decision, and what may + * run is still the execution's. + */ + +import type { Operation } from "effection"; + +import { inspectSyntax } from "./inspect.ts"; +import type { SyntaxCatalog } from "./inspect.ts"; +import { renderSyntaxMarkdown } from "./syntax-markdown.ts"; +import type { WorkflowImportAuthority } from "./components/bundle.ts"; +import type { DeclaredMarkdownComponent } from "./components/declared-markdown.ts"; +import type { IdentityComponent } from "./invocation-identity.ts"; +import type { ComponentRegistry } from "./types.ts"; + +/** + * The catalog in scope for the segments being expanded. + * + * Held by the execution and handed to core's own expansion by value, beside the + * import authority and the identity domains. It is not a Context: a context + * resolves by name, and a name is not a secret, so a document could build one + * and answer for the vocabulary it is shown. + */ +export interface CatalogObservation { + /** The catalog this site describes, rendered as Markdown. */ + observe(): Operation; +} + +/** + * A trusted host's statement of the catalog its profile describes. + * + * Captured by value with the rest of the installation, before any installed + * code, middleware or document code runs. It returns the catalog and core + * renders it, so a host cannot make its profile print differently from the way + * `xmd syntax` prints the same catalog. + */ +export type CatalogContribution = () => Operation; + +/** The selection inputs an execution captured, as catalog construction reads them. */ +export interface CapturedCatalogInputs { + readonly includes: readonly string[]; + /** The registrations this execution started with, captured before it ran. */ + readonly registry: ComponentRegistry; + readonly components: readonly IdentityComponent[]; + readonly declarations: readonly DeclaredMarkdownComponent[]; + /** The bundle this execution is closed over, when a trusted host installed one. */ + readonly workflow?: WorkflowImportAuthority; +} + +/** + * The observation one execution's root carries. + * + * Nothing is built until an occurrence asks. An execution whose document never + * writes `` enumerates no includes, parses no component and reads no + * frontmatter, so carrying the observation costs a run that does not use it + * nothing at all. + * + * Each ask builds afresh. Two authored occurrences are two observations, which + * is what makes an occurrence's retained catalog its own rather than a copy of + * whichever one ran first. + */ +export function rootCatalogObservation( + inputs: CapturedCatalogInputs, + contribution: CatalogContribution | undefined, +): CatalogObservation { + return { + *observe(): Operation { + return renderSyntaxMarkdown( + contribution === undefined ? yield* derived(inputs) : yield* contribution(), + ); + }, + }; +} + +function* derived(inputs: CapturedCatalogInputs): Operation { + return yield* inspectSyntax({ + includes: inputs.includes, + registry: inputs.registry, + components: inputs.components, + declarations: inputs.declarations, + ...(inputs.workflow === undefined ? {} : { workflow: inputs.workflow }), + }); +} + +/** + * An observation over a catalog a trusted boundary already decided. + * + * The narrowing seam. A canonical evaluation boundary that has already admitted + * the exact vocabulary a subtree may write installs the corresponding catalog + * for that subtree, and the enclosing observation is restored on leaving it. It + * adds nothing: the catalog handed here is the admission's, so an entry that is + * not in the admission cannot be in the observation. + */ +export function fixedCatalogObservation(catalog: SyntaxCatalog): CatalogObservation { + const rendered = renderSyntaxMarkdown(catalog); + return { + // deno-lint-ignore require-yield + *observe(): Operation { + return rendered; + }, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3dc9214e..dd1f5423 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,6 +9,7 @@ import type { Operation, Result } from "effection"; import type { Json as DurableJson } from "@executablemd/durable-streams"; import type { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { ComponentInvocation, InvocationForm } from "./invocation-identity.ts"; +import type { ProtectedComponent } from "./components/protected.ts"; export type Json = DurableJson; @@ -343,6 +344,17 @@ export type ComponentOrigin = */ export type ComponentSelection = | { kind: "structural"; construct: string } + /** + * A component canonical core claims the name of, ahead of every host or author + * tier. The declaration is core's own; the implementation belongs to whichever + * execution is running, so selection reports the contract and the execution + * supplies what it built (`components/protected.ts`). + */ + | { + kind: "protected"; + component: ProtectedComponent; + origin: Extract; + } | { kind: "registered"; definition: FunctionComponentDefinition; origin: ComponentOrigin } | { kind: "repository"; path: string } /** diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 711d02ed..19a4095a 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -30,6 +30,7 @@ import { agentIdentityComponents, CORE_COMPONENT_NAMES, inspectSyntax, + PROTECTED_COMPONENT_NAMES, registerComponents, RESERVED_STRUCTURAL, STRUCTURAL_DECLARATIONS, @@ -561,7 +562,12 @@ describe("Tier SY: selection decides", () => { it("SY10: falls back to a registration when no repository file supplies a name", function* () { const catalog = yield* catalogFor({ components: { kind: "directory" } }, ["components"]); - expect(names(builtIn(catalog)).sort()).toEqual([...CORE_COMPONENT_NAMES].sort()); + // Core's overridable defaults, plus the names canonical core protects: both + // are built-in to a reader, and the set is pinned exactly so a name arriving + // in either list has to be written down here. + expect(names(builtIn(catalog)).sort()).toEqual( + [...CORE_COMPONENT_NAMES, ...PROTECTED_COMPONENT_NAMES].sort(), + ); expect(userProvided(catalog)).toEqual([]); }); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts new file mode 100644 index 00000000..5749d60a --- /dev/null +++ b/packages/core/tests/syntax-component.test.ts @@ -0,0 +1,818 @@ +/** + * Tier SC — ``, the component canonical core owns. + * + * What a document may write here is a public question, and this is the public + * answer: the catalog for the site the element was written at, in the words + * `xmd syntax` prints. Three things follow, and every case here is about one of + * them. + * + * **The name is the engine's.** A repository `Syntax.md`, a bundled `Syntax`, an + * ordinary or reserved registration, a host declaration, import middleware and a + * definition from a second loaded copy can none of them answer for it. A catalog + * anything in the run could answer for describes nothing. + * + * **The answer is the site's.** The observation is built from the selection + * inputs the execution captured before any installation, middleware or document + * code ran, and it travels lexically on canonical core's own expansion + * authority — not through a context, where a name is not a secret. + * + * **One occurrence observes once.** It claims the identity this execution + * minted, records exactly `{ catalog }`, and a continuation hands that back + * without consulting the filesystem, the registry, the bundle or the host again. + * + * Protection is about the answer, not about power: the component receives one + * operation that observes catalog text and nothing else, and a catalog naming a + * component is not permission to run it. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, sleep, spawn, suspend, until } from "effection"; +import type { Operation } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { mkdtemp, realpath } from "node:fs/promises"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, useHostFiles } from "@executablemd/runtime"; + +import { Component } from "../src/component-api.ts"; +import { collect } from "../src/collect.ts"; +import { execute } from "../src/execute.ts"; +import { executeInstalled, sourceDigest } from "../host.ts"; +import type { DeclaredMarkdownComponent, ExecutionInstallation } from "../host.ts"; +import { inspectComponent, inspectSyntax } from "../src/inspect.ts"; +import { validateDocumentStructure } from "../src/document-validation.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { selectComponent } from "../src/components/select.ts"; +import { retainedSource } from "../src/root-source.ts"; +import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; +import { fixedCatalogObservation } from "../src/syntax-observation.ts"; +import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; +import type { ImportedDefinition } from "../src/components/import-authority.ts"; +import type { FunctionComponent, SyntaxCatalog } from "../mod.ts"; + +const ROOT_PATH = "documents/root.md"; + +/** The approved description, spelled here so a change to it fails a test. */ +const DESCRIPTION = + "Output available components and control flow constructs. `` renders the " + + "current catalog."; + +/** A catalog with one built-in entry per name, for a case that needs a marker. */ +function catalogOf(...names: readonly string[]): SyntaxCatalog { + return { + version: 1, + categories: [ + { kind: "structural", entries: [] }, + { + kind: "built-in", + entries: names.map((name) => ({ + kind: "component" as const, + name, + origin: { kind: "registered" as const, origin: "@executablemd/test", reserved: false }, + sourceKind: "registered" as const, + inspectability: "complete" as const, + forms: ["self-closing" as const], + props: { type: "object", properties: {}, additionalProperties: false }, + captures: [], + returnMode: "text" as const, + returns: { type: "string" }, + })), + }, + { kind: "user-provided", entries: [] }, + ], + }; +} + +/** A host that states the catalog its profile describes, and counts the asks. */ +function stating(catalog: SyntaxCatalog, calls: { count: number } = { count: 0 }) { + const installation: ExecutionInstallation = { + // deno-lint-ignore require-yield + *catalog(): Operation { + calls.count += 1; + return catalog; + }, + }; + return { installation, calls }; +} + +/** Run one root, with whatever installations the case supplies. */ +function run( + source: string, + installations: readonly ExecutionInstallation[] = [], + stream: InMemoryStream = new InMemoryStream(), + includes: readonly string[] = [], +): Operation { + return scoped(function* () { + return yield* collect( + yield* executeInstalled( + { ...retainedSource(ROOT_PATH, source), stream, includes: [...includes] }, + [...installations], + ), + ); + }); +} + +/** What one execution refused with, as a string. */ +function* refusal(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error("expected the operation to be refused"); +} + +/** Every retained catalog observation, in order. */ +function observations(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === "syntax_catalog", + ); +} + +/** A continuation stream: everything one run recorded but its terminals. */ +function* continuing(stream: InMemoryStream): Operation { + const partial = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + if (event.type === "close") { + continue; + } + yield* partial.append(event); + } + return partial; +} + +/** The same history with one retained observation replaced. */ +function* tampered( + stream: InMemoryStream, + replace: (value: Json) => Json, +): Operation { + const partial = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + if (event.type === "close") { + continue; + } + if ( + event.type === "yield" && + event.description.type === "syntax_catalog" && + event.result.status === "ok" + ) { + yield* partial.append({ + ...event, + result: { status: "ok", value: replace(event.result.value ?? null) }, + }); + continue; + } + yield* partial.append(event); + } + return partial; +} + +/** A working directory of this case's own, torn down on the way out. */ +function useWorkingDirectory(body: (dir: string) => Operation): Operation { + return scoped(function* () { + const made = yield* until(mkdtemp(join(tmpdir(), "xmd-syntax-"))); + const dir = yield* until(realpath(made)); + yield* ensure(() => rm(dir, { recursive: true, force: true })); + yield* API.Env.around({ + // deno-lint-ignore require-yield + *cwd() { + return dir; + }, + }); + yield* useHostFiles(); + return yield* body(dir); + }); +} + +describe("Tier SC — what one occurrence answers", () => { + it("SC1: the bare form renders the catalog once, and `as` binds the same text", function* () { + const { installation } = stating(catalogOf("Marker")); + const bare = yield* run("\n", [installation]); + expect(String(bare)).toContain("### ``"); + // Once, not twice: one occurrence is one rendering. + expect(String(bare).split("### ``").length - 1).toBe(1); + + const captured = yield* run('\nbound:{catalog}\n', [installation]); + // The same text, and the occurrence itself emitted nothing — what is in the + // document is the binding this case interpolated, not a second copy. + expect(String(captured)).toContain("bound:"); + expect(String(captured)).toContain("### ``"); + expect(String(captured).indexOf("### ``")).toBeGreaterThan( + String(captured).indexOf("bound:"), + ); + expect(String(captured).split("### ``").length - 1).toBe(1); + }); + + it("SC2: it renders exactly what the shared Markdown renderer produces", function* () { + const catalog = catalogOf("Marker", "Other"); + const { installation } = stating(catalog); + const bare = yield* run('{catalog}', [installation]); + // The same function `xmd syntax` renders with, not a second one that agrees + // today: an invocation and the command cannot describe one profile in two + // sets of words. + expect(String(bare)).toBe(renderSyntaxMarkdown(catalog)); + }); + + it("SC3: a paired spelling and an authored prop refuse before any observation", function* () { + const paired = stating(catalogOf("Marker")); + expect(yield* refusal(run("content\n", [paired.installation]))).toContain( + "written self-closing", + ); + expect(paired.calls.count).toBe(0); + + const propped = stating(catalogOf("Marker")); + expect(yield* refusal(run('\n', [propped.installation]))).toContain( + "mode", + ); + expect(propped.calls.count).toBe(0); + + // The positive control for the same host: the accepted spelling observes. + const accepted = stating(catalogOf("Marker")); + expect(String(yield* run("\n", [accepted.installation]))).toContain("Marker"); + expect(accepted.calls.count).toBe(1); + }); + + it("SC4: one occurrence observes once, two observe independently, a binding observes neither again", function* () { + const one = stating(catalogOf("Marker")); + yield* run('{catalog}{catalog}{catalog}', [one.installation]); + expect(one.calls.count).toBe(1); + + const two = stating(catalogOf("Marker")); + yield* run("\n\n", [two.installation]); + expect(two.calls.count).toBe(2); + + // Two identities, so two records rather than one record read twice. + const stream = new InMemoryStream(); + yield* run("\n\n", [stating(catalogOf("Marker")).installation], stream); + expect(observations(yield* stream.readAll()).length).toBe(2); + }); +}); + +describe("Tier SC — the name canonical core owns", () => { + it("SC5: a repository Syntax.md, Syntax.ts and directory candidate never win", function* () { + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile(join(dir, "Syntax.md"), "a repository catalog\n"); + yield* writeTextFile(join(dir, "Nearby.md"), "a nearby repository component\n"); + const { installation } = stating(catalogOf("Marker")); + + const output = String( + yield* run("\n\n", [installation], undefined, [dir]), + ); + // The protected component answered, and the repository file did not. + expect(output).toContain("### ``"); + expect(output).not.toContain("a repository catalog"); + // The positive control: repository discovery is active in this very run, + // so the absence above is protection rather than a search that never ran. + expect(output).toContain("a nearby repository component"); + }); + }); + + it("SC6: selection reports the protected tier ahead of every other", function* () { + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile(join(dir, "Syntax.md"), "a repository catalog\n"); + const selected = yield* selectComponent(SYNTAX_COMPONENT, { includes: [dir] }); + expect(selected.kind).toBe("protected"); + // The origin is core's, and reserved — the catalog's word for a name a + // document cannot take back. + expect(selected.kind === "protected" ? selected.origin : undefined).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }); + }); + }); + + it("SC7: an ordinary and a reserved registration named Syntax are both refused", function* () { + const refused = yield* refusal( + scoped(function* () { + yield* registerComponents([ + { + name: "Syntax", + origin: "@executablemd/test", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "replaced"; + }, + }, + ]); + }), + ); + expect(refused).toContain("canonical core owns that name"); + + const reservedRefusal = yield* refusal( + scoped(function* () { + yield* registerComponents([ + { + name: "Syntax", + origin: "@executablemd/test", + reserved: true, + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "replaced"; + }, + }, + ]); + }), + ); + expect(reservedRefusal).toContain("canonical core owns that name"); + }); + + it("SC8: the refused batch registers nothing, and an adjacent registration still works", function* () { + const good = { + name: "Adjacent", + origin: "@executablemd/test", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "adjacent ran"; + }, + }; + // The batch is refused whole: `Adjacent` is beside the refused name, and + // nothing of it survives. + yield* refusal( + registerComponents([ + good, + { + name: "Syntax", + origin: "@executablemd/test", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "replaced"; + }, + }, + ]), + ); + const after = yield* selectComponent("Adjacent", { + includes: [], + registry: yield* Component.operations.registry, + }); + expect(after.kind).toBe("unresolved"); + + // The positive control: registration is available in this very scope. + yield* registerComponents([good]); + expect( + (yield* selectComponent("Adjacent", { + includes: [], + registry: yield* Component.operations.registry, + })).kind, + ).toBe("registered"); + }); + + it("SC9: a host that declares Markdown called Syntax is refused before the root import", function* () { + const source = "a declared catalog\n"; + const declaration: DeclaredMarkdownComponent = { + name: "Syntax", + origin: "@executablemd/test/Syntax.md", + source, + digest: sourceDigest(source), + }; + const stream = new InMemoryStream(); + expect( + yield* refusal(run("\n", [{ declarations: [declaration] }], stream)), + ).toContain("canonical core owns that name"); + // Before the root import: nothing was imported and nothing was observed. + const events = yield* stream.readAll(); + expect(events.filter((event) => event.type === "yield").length).toBe(0); + + // The positive control: an adjacent declaration under another name is + // admitted and runs, so the refusal is about the name. + const adjacent: DeclaredMarkdownComponent = { + name: "Policy", + origin: "@executablemd/test/Policy.md", + source, + digest: sourceDigest(source), + }; + expect(String(yield* run("\n", [{ declarations: [adjacent] }]))).toContain( + "a declared catalog", + ); + }); + + it("SC10: a workflow bundle member called Syntax is refused before the root import", function* () { + const bundled = { + name: "Syntax", + path: "components/Syntax.md", + sourceHash: "0".repeat(40), + content: "a bundled catalog\n", + }; + const adjacent = { + name: "Bundled", + path: "components/Bundled.md", + sourceHash: "1".repeat(40), + content: "a bundled component\n", + }; + expect( + yield* refusal(run("\n", [{ bundle: { components: [bundled, adjacent] } }])), + ).toContain("canonical core owns that name"); + + // The positive control: the same bundle without the protected name installs + // and its member runs. + expect(String(yield* run("\n", [{ bundle: { components: [adjacent] } }]))).toContain( + "a bundled component", + ); + }); +}); + +describe("Tier SC — what the chain may and may not do", () => { + /** A handler that answers `Syntax` with whatever `answer` produces. */ + function answering( + answer: (real: ImportedDefinition) => ImportedDefinition, + ): ExecutionInstallation { + return { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + if (name !== SYNTAX_COMPONENT) { + return yield* next(name, position); + } + return answer(yield* next(name, position)); + }, + }, + { at: "max" }, + ); + }, + }; + } + + it("SC11: ordinary delegation reaches canonical Syntax", function* () { + const seen: string[] = []; + const observing: ExecutionInstallation = { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + seen.push(name); + return yield* next(name, position); + }, + }, + { at: "max" }, + ); + }, + }; + const { installation } = stating(catalogOf("Marker")); + expect(String(yield* run("\n", [installation, observing]))).toContain("Marker"); + // The handler observed the import it could not answer. + expect(seen).toContain(SYNTAX_COMPONENT); + }); + + it("SC12: a handler that answers, substitutes, mutates or copies runs no replacement", function* () { + const replacement: FunctionComponent = function* () { + return "a replaced catalog"; + }; + const cases: [string, (real: ImportedDefinition) => ImportedDefinition][] = [ + [ + "answers without delegating", + () => ({ + kind: "function", + name: SYNTAX_COMPONENT, + props: { type: "object", properties: {}, additionalProperties: false }, + fn: replacement, + }), + ], + [ + "substitutes a copy that describes the same contract", + (real) => ({ ...Object(real), fn: replacement }), + ], + [ + "mutates what canonical execution produced", + (real) => { + Reflect.set(Object(real), "fn", replacement); + return real; + }, + ], + ]; + + for (const [, answer] of cases) { + const { installation, calls } = stating(catalogOf("Marker")); + const refused = yield* refusal(run("\n", [installation, answering(answer)])); + expect(refused).toContain("canonical core owns"); + // Refused before the body: no catalog was observed for the replacement. + expect(calls.count).toBe(0); + } + }); + + it("SC13: a handler that redirects the name, or delegates twice, answers nothing", function* () { + const redirecting: ExecutionInstallation = { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + // The answer canonical execution produced for another name. + return name === SYNTAX_COMPONENT + ? yield* next("Other", position) + : yield* next(name, position); + }, + }, + { at: "max" }, + ); + }, + }; + expect( + yield* refusal(run("\n", [stating(catalogOf("Marker")).installation, redirecting])), + ).toBeTruthy(); + + const twice: ExecutionInstallation = { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + if (name !== SYNTAX_COMPONENT) { + return yield* next(name, position); + } + yield* next(name, position); + return yield* next(name, position); + }, + }, + { at: "max" }, + ); + }, + }; + // Two canonical selections in one frame yield no domain, so the occurrence + // can name no durable operation and the invocation refuses. + expect( + yield* refusal(run("\n", [stating(catalogOf("Marker")).installation, twice])), + ).toBeTruthy(); + }); + + it("SC14: a deliberate middleware refusal stays a refusal", function* () { + const refusing: ExecutionInstallation = { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + if (name === SYNTAX_COMPONENT) { + throw new Error("this host refuses the catalog"); + } + return yield* next(name, position); + }, + }, + { at: "max" }, + ); + }, + }; + const { installation, calls } = stating(catalogOf("Marker")); + expect(yield* refusal(run("\n", [installation, refusing]))).toContain( + "this host refuses the catalog", + ); + expect(calls.count).toBe(0); + }); + + it("SC15: a document-authored context and a look-alike observation change nothing", function* () { + // Nothing a document writes reaches the observation: it is not addressed by + // name. The strongest thing an authored document can do is register and + // bind, and the catalog is unchanged by both. + const { installation } = stating(catalogOf("Marker")); + const source = [ + '', + '', + "{observed}", + "", + ].join("\n"); + const output = String(yield* run(source, [installation])); + expect(output).toContain("### ``"); + expect(output).not.toContain("a planted catalog"); + }); +}); + +describe("Tier SC — the site the catalog describes", () => { + it("SC16: the derived catalog reports this execution's own includes and registry", function* () { + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile(join(dir, "Local.md"), "a local component\n"); + // No host contribution: canonical core derives the catalog from the + // selection inputs this execution captured. + const output = String(yield* run("\n", [], undefined, [dir])); + expect(output).toContain("### ``"); + // And it describes itself, once, with the approved description. + expect(output).toContain("### ``"); + expect(output).toContain(DESCRIPTION); + expect(output).toContain("`@executablemd/core` (reserved registration)"); + }); + }); + + it("SC17: a workflow root observes its own bundle without running a member", function* () { + const entered: string[] = []; + const bundle = { + components: [ + { + name: "Bundled", + path: "components/Bundled.md", + sourceHash: "1".repeat(40), + content: "a bundled component\n", + }, + ], + }; + const stream = new InMemoryStream(); + const output = String(yield* run("\n", [{ bundle }], stream)); + expect(output).toContain("### ``"); + // Described, not run: nothing imported or expanded the member. + expect(entered).toEqual([]); + const imported = (yield* stream.readAll()).filter( + (event) => event.type === "yield" && event.description.type === "import_component", + ); + expect( + imported.some((event) => event.type === "yield" && event.description.name === "Bundled"), + ).toBe(false); + }); + + it("SC18: a declared Markdown component's own body observes the site it inherited", function* () { + const source = ['', "policy sees {catalog}", ""].join("\n"); + const declaration: DeclaredMarkdownComponent = { + name: "Policy", + origin: "@executablemd/test/Policy.md", + source, + digest: sourceDigest(source), + }; + const { installation } = stating(catalogOf("Marker")); + const output = String( + yield* run("\n", [installation, { declarations: [declaration] }]), + ); + expect(output).toContain("policy sees"); + expect(output).toContain("### ``"); + }); +}); + +describe("Tier SC — the record one occurrence keeps", () => { + it("SC19: the retained payload is closed on exactly { catalog }", function* () { + const stream = new InMemoryStream(); + yield* run("\n", [stating(catalogOf("Marker")).installation], stream); + const [observation] = observations(yield* stream.readAll()); + if (observation?.type !== "yield" || observation.result.status !== "ok") { + throw new Error("the run retained no catalog observation"); + } + const value = Object(observation.result.value); + expect(Object.keys(value)).toEqual(["catalog"]); + expect(typeof value.catalog).toBe("string"); + }); + + it("SC20: a continuation restores the catalog after the environment moves, and asks nothing", function* () { + const first = new InMemoryStream(); + const before = String( + yield* run("\n", [stating(catalogOf("Before")).installation], first), + ); + expect(before).toContain("### ``"); + + // The environment moved: the host now states a different profile, and the + // contribution refuses to answer at all. + const moved: ExecutionInstallation = { + // deno-lint-ignore require-yield + *catalog(): Operation { + throw new Error("the continuation rebuilt the catalog"); + }, + }; + const continued = String(yield* run("\n", [moved], yield* continuing(first))); + expect(continued).toContain("### ``"); + expect(continued).not.toContain("### ``"); + + // A fresh execution sees the moved environment, which is what shows the + // restoration above was retention rather than the observation being inert. + expect( + String(yield* run("\n", [stating(catalogOf("After")).installation])), + ).toContain("### ``"); + }); + + it("SC21: a missing, extra or wrong-typed retained payload refuses before output or binding", function* () { + const cases: [string, (value: Json) => Json][] = [ + ["the member is missing", () => ({})], + ["an unknown member was added", (value) => ({ ...Object(value), extra: true })], + ["the member has the wrong type", () => ({ catalog: 7 })], + ]; + for (const [, replace] of cases) { + const first = new InMemoryStream(); + yield* run("\n", [stating(catalogOf("Marker")).installation], first); + const hostile = yield* tampered(first, replace); + const refused = yield* refusal( + run( + 'bound:{catalog}', + [stating(catalogOf("Marker")).installation], + hostile, + ), + ); + expect(refused).toContain("is not a catalog this version can read"); + } + }); + + it("SC22: a cancelled observation tears down and commits no catalog", function* () { + const teardown: string[] = []; + const stream = new InMemoryStream(); + const hanging: ExecutionInstallation = { + *catalog(): Operation { + yield* ensure(function* () { + teardown.push("released"); + }); + yield* suspend(); + throw new Error("unreachable"); + }, + }; + + yield* scoped(function* () { + const running = yield* spawn(function* () { + yield* run("\n", [hanging], stream); + }); + // Long enough for the observation to be entered and suspended. + yield* sleep(20); + yield* running.halt(); + }); + + // The structured teardown ran, and nothing successful was committed. + expect(teardown).toEqual(["released"]); + const committed = observations(yield* stream.readAll()).filter( + (event) => event.type === "yield" && event.result.status === "ok", + ); + expect(committed).toEqual([]); + }); +}); + +describe("Tier SC — observation is never authority", () => { + it("SC23: a catalog naming a component neither registers nor resolves it", function* () { + // The strongest form: the trusted host itself states a catalog naming a + // component nothing supplies. + const { installation } = stating(catalogOf("Phantom")); + const output = String(yield* run("\n", [installation])); + expect(output).toContain("### ``"); + + // It is still a name nothing answers for. + expect(yield* refusal(run("\n", [installation]))).toContain( + "Cannot resolve component: Phantom", + ); + expect((yield* selectComponent("Phantom", { includes: [] })).kind).toBe("unresolved"); + }); + + it("SC24: the component is described identically by inspection and by validation", function* () { + const catalog = yield* inspectSyntax({ includes: [] }); + const entry = catalog.categories[1].entries.find((candidate) => candidate.name === "Syntax"); + expect(entry).toBeDefined(); + expect(entry?.description).toBe(DESCRIPTION); + expect(entry?.forms).toEqual(["self-closing"]); + expect(entry?.returnMode).toBe("text"); + expect(entry?.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); + expect(entry?.origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }); + // Exactly one entry, in exactly one category. + const everywhere = catalog.categories.flatMap((category) => + category.entries.filter((candidate) => candidate.name === "Syntax"), + ); + expect(everywhere.length).toBe(1); + + const described = yield* inspectComponent({ name: "Syntax", includes: [] }); + expect(described.kind).toBe("registered"); + + // Validation reads the same declaration, so a paired spelling is invalid + // before anything runs and the self-closing one is valid. + const bad = yield* validateDocumentStructure({ + ...retainedSource("", "content\n"), + includes: [], + }); + expect(bad.diagnostics.some((issue) => issue.code === "invocation-form-invalid")).toBe(true); + const good = yield* validateDocumentStructure({ + ...retainedSource("", "\n"), + includes: [], + }); + expect(good.diagnostics).toEqual([]); + }); + + /** + * The seam a trusted evaluation boundary narrows through. + * + * `` admits an exact vocabulary before it expands a generated + * fragment, and the observation it installs for that subtree is that + * admission's own catalog — it cannot add an entry the admission does not + * hold, because it is handed the catalog rather than asked to build one. + * Installing it for an evaluation subtree is #713's; that the observation is + * the catalog and nothing more is this. + */ + it("SC25b: a narrowed observation answers with exactly the catalog it was given", function* () { + const narrowed = catalogOf("Admitted"); + const observation = fixedCatalogObservation(narrowed); + expect(yield* observation.observe()).toBe(renderSyntaxMarkdown(narrowed)); + // Nothing of the enclosing site leaks into it: a name the wider profile has + // is absent, because the catalog it was handed does not hold one. + expect(yield* observation.observe()).not.toContain("### ``"); + }); + + it("SC25: an execution that carries no observation refuses rather than inventing one", function* () { + // `execute()` driven directly still carries one, so the case that has none + // is an expansion driven outside an execution — which is what a component + // reaching for a catalog with nothing established would meet. + const output = String( + yield* collect( + yield* execute({ + ...retainedSource(ROOT_PATH, "\n"), + stream: new InMemoryStream(), + includes: [], + }), + ), + ); + // An ordinary `execute()` derives its own, so this is the positive control + // that the derived path needs no host at all. + expect(output).toContain("### ``"); + }); +}); diff --git a/packages/core/tests/syntax-loaded-copy.test.ts b/packages/core/tests/syntax-loaded-copy.test.ts new file mode 100644 index 00000000..9c80fdd7 --- /dev/null +++ b/packages/core/tests/syntax-loaded-copy.test.ts @@ -0,0 +1,222 @@ +/** + * Tier SL — a protected implementation from a second loaded copy answers for + * nothing. + * + * A component can be loaded from disk beside its own copy of core: that is what + * `--include` does, and what a middleware package holding its own copy is. So + * "another loaded copy cannot answer for ``" is a claim about ordinary + * arrangements rather than a hypothetical, and it has two halves. + * + * **The answer is refused.** Canonical execution issues a witness for the + * definition it produced and verifies it where the component is invoked, keyed + * by the object itself. A definition another copy built is not that object, + * whatever it looks like. + * + * **And a body is unreachable anyway.** The table a protected body lives in + * belongs to the execution that built the implementation, inside the copy that + * built it, so this execution holds no body for a function another copy's + * installation created. That half is what makes the refusal above a boundary + * rather than a single check. + * + * The separate copy is built with `deno bundle`, which is Deno's, so this file + * runs under Deno alone and is registered in the runtime exclusions. What it is + * about — the witness comparison and the private body table — is proved under + * all three runtimes by `syntax-component.test.ts` against handler-built + * answers; what is only provable here is that a *real* second copy is one of + * those answers. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { rm } from "@effectionx/fs"; +import { exec } from "@effectionx/process"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; + +import { Component } from "../src/component-api.ts"; +import { collect } from "../src/collect.ts"; +import { executeInstalled } from "../host.ts"; +import type { ExecutionInstallation } from "../host.ts"; +import { retainedSource } from "../src/root-source.ts"; +import { SYNTAX_COMPONENT, props as syntaxProps } from "../src/components/Syntax.ts"; +import type { FunctionComponentDefinition } from "../src/types.ts"; + +const PROTECTION_MODULE = fileURLToPath(new URL("../src/invocation-identity.ts", import.meta.url)); +const REPOSITORY = fileURLToPath(new URL("../../../", import.meta.url)); + +/** What the bundled copy exposes: its own installation, with its own tables. */ +interface LoadedCopy { + installIdentities( + components: readonly unknown[], + privateComponents: readonly unknown[], + protectedComponents: readonly unknown[], + ): { + protected: ReadonlyMap; + protectedBodies: { body(fn: unknown): unknown }; + activate(): void; + }; +} + +function isLoadedCopy(value: unknown): value is LoadedCopy { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "installIdentities") === "function" + ); +} + +/** + * `packages/core/src/invocation-identity.ts`, bundled and evaluated as its own + * module. + * + * The bundle is what makes the copy separate: importing the source path again + * would resolve to the module this test already holds, and share the private + * body table with it. The declaration handed to it below is this test's, because + * the component module itself does not bundle; what is genuinely the second + * copy's is what decides — the implementation wrapper it built and the table its + * body lives in. + */ +function useSeparateCopy(): Operation { + return resource(function* (provide) { + const directory = yield* until(mkdtemp(join(tmpdir(), "sl-syntax-"))); + yield* ensure(() => rm(directory, { recursive: true, force: true })); + const bundle = join(directory, "protection.js"); + + // `process.execPath` under Deno is the deno binary, so the driver stays + // typed against node:process rather than a runtime global. + const built = yield* exec(process.execPath, { + arguments: [ + "bundle", + "--frozen", + "--node-modules-dir=none", + PROTECTION_MODULE, + "--output", + bundle, + ], + cwd: REPOSITORY, + }).join(); + if (built.code !== 0) { + throw new Error(`could not bundle the identity module:\n${built.stdout}${built.stderr}`); + } + + const loaded: unknown = yield* until(import(`file://${bundle}`)); + if (!isLoadedCopy(loaded)) { + throw new Error("the bundled copy does not expose the protection surface"); + } + yield* provide(loaded); + }); +} + +/** + * A handler that delegates the protected import and then answers with something + * else. + * + * Delegating first is the strongest form: this is a handler that saw canonical + * execution's own answer, not one that skipped the chain. + */ +function answering(definition: unknown): ExecutionInstallation { + return { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + if (name !== SYNTAX_COMPONENT) { + return yield* next(name, position); + } + yield* next(name, position); + return definition as FunctionComponentDefinition; + }, + }, + { at: "max" }, + ); + }, + }; +} + +function runRoot(installations: readonly ExecutionInstallation[]): Operation { + return scoped(function* () { + return yield* collect( + yield* executeInstalled( + { + ...retainedSource("documents/root.md", "\n"), + stream: new InMemoryStream(), + includes: [], + }, + [...installations], + ), + ); + }); +} + +function* refusal(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error("expected the operation to be refused"); +} + +describe("Tier SL — a separately loaded protected implementation", () => { + it("SL1: an implementation another copy built answers for nothing here", function* () { + const copy = yield* useSeparateCopy(); + + // An installation the other copy performed: it minted the domain, built the + // implementation and kept the body in its own table. Nothing here is a fake + // — this is that copy's real protected-component path. + const installed = copy.installIdentities( + [], + [], + [ + { + name: SYNTAX_COMPONENT, + origin: "@executablemd/core", + props: syntaxProps, + forms: ["self-closing"], + // deno-lint-ignore require-yield + build: () => + // deno-lint-ignore require-yield + function* (): Operation { + return "a foreign catalog"; + }, + }, + ], + ); + installed.activate(); + const foreign = installed.protected.get(SYNTAX_COMPONENT); + if (foreign === undefined) { + throw new Error("the bundled copy built no protected implementation"); + } + // The premise, stated as a fact rather than assumed: that copy holds a body + // for its own implementation. + expect(installed.protectedBodies.body(foreign.fn)).toBeDefined(); + + const refused = yield* refusal(runRoot([answering(foreign)])); + expect(refused).toContain("canonical core owns"); + + // The positive control, in the same shape: a handler that delegates and + // answers with what came back runs the canonical component. + const output = yield* runRoot([ + { + *install() { + yield* Component.around( + { + *importComponent([name, position], next) { + return yield* next(name, position); + }, + }, + { at: "max" }, + ); + }, + }, + ]); + expect(String(output)).toContain("### ``"); + expect(String(output)).not.toContain("a foreign catalog"); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 22765142..a59e8b0c 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -155,6 +155,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "exercises Deno-private node:sqlite transaction identities and real SQLite savepoint failure behavior; node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/365", }, + { + path: "packages/core/tests/syntax-loaded-copy.test.ts", + reason: + "builds the second copy of the protected-component module with `deno bundle`, which is Deno's; the witness comparison and the private body table it proves are runtime-neutral and are also covered by syntax-component.test.ts under all three", + issue: DERIVED_SCOPE, + }, { path: "packages/core/tests/loaded-copy-files.test.ts", reason: diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 90e17e55..43e8b876 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -221,7 +221,6 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(plan.forms).toEqual(["paired"]); // And no private capability is syntax a document may write, in any build. for (const name of [ - "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", @@ -231,6 +230,23 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(entries.map((entry: { name?: string }) => entry?.name)).not.toContain(name); } + // `` is public in every build, and this one describes it exactly + // once, from the canonical origin, with the approved description. A package + // that lost the protected tier would either omit it or list it twice. + const syntax = entries.filter((entry: { name?: string }) => entry?.name === "Syntax"); + expect(syntax).toHaveLength(1); + expect(syntax[0].origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }); + expect(syntax[0].forms).toEqual(["self-closing"]); + expect(syntax[0].returnMode).toBe("text"); + expect(syntax[0].description).toBe( + "Output available components and control flow constructs. `` renders the " + + "current catalog.", + ); + // The command's public grammar travels with those bytes. `--run` is gone, // and this directory has no agent to reach and no `DEFAULT_AGENT_NAME` that // resolves here — so a build that still accepted the switch would fail on diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 7bb090af..462d5a15 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -91,7 +91,6 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => // write. const names = entries.map((entry: { name?: string }) => entry?.name); for (const name of [ - "Syntax", "PlanInputs", "PlanAuthorship", "PlanProgress", @@ -101,6 +100,24 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => expect(names).not.toContain(name); } + // `` is public, and the compiled binary describes it exactly once + // from the canonical origin. The protected tier ships inside the binary + // rather than being assembled by whoever installs the profile, so a build + // that lost it would describe no catalog component at all. + const syntax = entries.filter((entry: { name?: string }) => entry?.name === "Syntax"); + expect(syntax).toHaveLength(1); + expect(syntax[0].origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }); + expect(syntax[0].forms).toEqual(["self-closing"]); + expect(syntax[0].returnMode).toBe("text"); + expect(syntax[0].description).toBe( + "Output available components and control flow constructs. `` renders the " + + "current catalog.", + ); + // The command surface those bytes belong to is source-only in this build // too: help describes both explicit compositions and names no option that // would run the approved program. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index f91b4548..8bdd954a 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2657,16 +2657,20 @@ A component name is resolved in tiers, and the first tier that answers wins: repository file named after one never stands in for it. A structural name written where its construct gives it no meaning is a printed error, not a missing component. -2. **a host claiming the name** — a reserved registration protecting a language +2. **a component canonical core protects** — the engine's own claim rather than + a host's, so the name means the same thing in every execution. `` + (§5.3.1) is the one member. The table is the resolver's own and is consulted + unconditionally, so no option a caller passes puts anything in front of it. +3. **a host claiming the name** — a reserved registration protecting a language or security invariant, or a *declared Markdown component*: exact first-party Markdown a trusted host handed this execution. Both claim the name rather than offering a default for it, so two claims on one name are refused where they are installed and this tier never chooses between them. -3. **the workflow component bundle** this execution is closed over, when a +4. **the workflow component bundle** this execution is closed over, when a trusted host installed one. -4. **a repository-local file**, by the candidate order below. -5. **a registered default**, including the components core supplies. -6. **nothing**, which is the unresolved printed error. +5. **a repository-local file**, by the candidate order below. +6. **a registered default**, including the components core supplies. +7. **nothing**, which is the unresolved printed error. So a repository component overrides any ordinary package default, core's included, and a reserved registration overrides the repository. Only genuine @@ -2674,6 +2678,60 @@ absence falls through to a default: a candidate that exists but cannot be read, imported, parsed, or compiled fails where it is loaded, so a broken local component is never quietly replaced. +#### 5.3.1 ``, the protected catalog component + +`` outputs the components and control-flow constructs a document may +write at the site it is written at, as the Markdown `xmd syntax` prints. One +catalog construction and one Markdown renderer serve both, so an operator +printing a profile and an agent being told what to write are never given +different accounts of one environment. + +```mdx + +``` + +renders the catalog where it is written. It is a **text component**: the ordinary +engine-owned `as` captures the same text and emits nothing. + +```mdx + +``` + +It is **self-closing only** and declares no props. A paired spelling and any +authored prop are refused before a catalog is observed. + +**Canonical core owns the name.** A repository `Syntax.md`, `Syntax.ts` or +directory candidate never wins selection; an ordinary or reserved registration +under the name is refused atomically at registration; a workflow bundle member +and a host's declared Markdown under the name are each refused at admission, +before the root import; and `Component.importComponent` middleware may observe +the import, delegate it and refuse it by throwing, but cannot answer it, replace +what came back, mutate it, or hand back a definition kept from another import or +built by another loaded copy. + +**The catalog is the execution's own.** Canonical core builds it at the root from +the selection inputs that execution captured before any installation, middleware +or document code ran — its includes, the registry it started with, the identity +components and exact Markdown its host declared, and the component bundle it is +closed over — and carries it lexically on canonical core's own expansion +authority. A trusted host may state the catalog its profile describes instead, +captured on the same terms; one execution accepts one, and two are refused rather +than ordered. Nothing is built until an occurrence asks. + +Seeing a component in a catalog grants nothing. It neither registers, resolves +nor authorizes that component: what a name means is still this section's +decision, and what may run is still the execution's. + +**Each occurrence observes once.** It claims the durable identity the execution +minted for it, performs one `syntax_catalog` durable observation, and retains +exactly `{ catalog: string }`. On continuation that record is parsed as a closed +protocol and returned without consulting the filesystem, the registry, the +bundle, the host or the lexical observation again; a missing, additional or +mistyped member is stale input and refuses before output or binding. Two authored +occurrences are two identities and two observations, repeated reads of one +binding observe nothing again, and a failed or cancelled observation completes +its teardown and commits no catalog. + #### The run profile's repository declarations Thirteen names — `Repository`, `Worktree`, `Dir`, `Git.Switch`, `Git.Add`, @@ -2842,14 +2900,15 @@ it emits that source where the component is written, and `as` is ordinary text capture: the same bytes are bound and nothing is emitted. Neither form evaluates the source, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, and an ordinary `` -expands no progress body at all. Its six private capabilities — ``, -``, ``, ``, `` and +expands no progress body at all. Its five private +capabilities — ``, ``, ``, +`` and `` — are the closure those exact bytes carry, and are syntax no -document may write. `` observes and durably freezes the host-supplied run -vocabulary before `` records the instruction identity and session -facts. Their retained records are separate closed protocols, exactly -`{ syntax }` and `{ instruction }`; malformed members refuse before authorship, -and continuation restores the catalog already shown rather than rebuilding it. +document may write. The vocabulary the Agent is shown is not among them: the +packaged bytes write the public `` (§5.3.1), whose own +`syntax_catalog` observation retains exactly `{ catalog }`, so a continuation +restores the catalog the run actually showed rather than rebuilding it, and +`` retains exactly `{ instruction }` beside it. [The plan command](./plan-command-spec.md) is the contract. **Which Agent a Plan is written with is the host's to say, not the Component's.** @@ -10817,7 +10876,7 @@ rather than restating. | PO6/PO7 | Channels and grammar | A non-terminal stderr receives normalized Markdown and a stated terminal receives it rendered, while stdout and `--output` stay byte-identical; `--verbose` and `--journal` work on either side of the request, help carries them and the journal warning, and the short aliases, every removed spelling and a retained option that reaches this grammar written where the journal path goes all refuse before any work, while `--help` keeps its ordinary precedence | | PO8/PO9/PO16 | The journal file | No `--journal` writes no file; one creates the path before the catalog and the first turn, parses as the existing JSONL in commit order, ends terminally and holds no program execution; an existing path and an uncreatable one each report their exact refusal and reach nothing; and an ordinary failure — where no append failed — leaves a wholly parseable file with no partial trailing record | | PO10–PO12 | The secret and persistence boundaries | A secret in a draft or in a failed check's findings reaches neither the progress nor the file while the earlier prefix stays readable, and the same values without it are shown and recorded; a refused entry reports the exact journal-write diagnostic and preserves what committed | -| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is built once, from private ``, while continuation restores that snapshot without rebuilding it | +| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is observed once, through public ``, while continuation restores that observation without rebuilding it | ### Tier UG — The `xmd upgrade` command diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 0a6d0fca..3df36f5b 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -342,9 +342,17 @@ The host supplies two fixed internal inputs as that root's props: They are the adapter's own, and nothing a Plan declares is bound here: the properties a Plan's root declares are resolved by whoever runs it. The catalog -is not among them: private `` observes it through a closure the host -captured, so an authored phase can say that preparation is starting before the -observation happens. +is not among them either. The command states the vocabulary its profile +describes at the execution boundary, captured before any installed code runs, +and the packaged Component reaches it by writing the public `` any +document may write — so an authored phase can say that preparation is starting +before the observation happens, and the catalog the Agent is shown is the one an +operator can print. + +The profile it states is the ordinary `run` one, in the caller's includes. A +Plan is a program a later `xmd run` executes, and this authorship execution +searches no repository and refuses almost every capability, so a catalog derived +from it would describe a vocabulary the approved program would not have. **The root is an adapter, not the workflow.** Its whole body is two elements: it projects `props.request` into `` without adding whitespace, supplies @@ -388,26 +396,30 @@ surfaces' endings, each written once. The command's wording is unchanged; the component's says that no Plan was returned rather than that nothing was output or run. TypeScript supplies neither the words nor the choice between them. -**The six private capabilities.** They are components only these exact bytes may +**The five private capabilities.** They are components only these exact bytes may write, declared by the host with the definition and revoked with the execution. -`` observes the host-supplied run vocabulary -and retains the exact catalog the Agent receives. `` freezes the -instruction identity, session placement, surface and whether that placement -outlives the invocation, and refuses a continuation whose instructions render -differently — as stale input, before a directory, a provider, a turn or a review -exists. Paired `` installs the constrained frame and does not -return until every part of it has torn down; paired `` says which -phase is running; `` answers about one draft without executing it; -and `` structurally admits the approved bytes after that teardown and -retains them as one Plan artifact — the invocation identity, the instruction -identity, the approved source, its digest and that successful admission — before -the Component renders them. - -The syntax snapshot and Plan inputs are separate closed durable protocols. -`` retains exactly `{ syntax }`; `` retains exactly -`{ instruction }`. A continuation restores the catalog it actually showed the -Agent rather than observing a moved component environment, while a missing, -additional or mistyped member in either record refuses before authorship begins. +`` freezes the instruction identity, session placement, surface and +whether that placement outlives the invocation, and refuses a continuation whose +instructions render differently — as stale input, before a directory, a provider, +a turn or a review exists. Paired `` installs the constrained +frame and does not return until every part of it has torn down; paired +`` says which phase is running; `` answers about one +draft without executing it; and `` structurally admits the approved +bytes after that teardown and retains them as one Plan artifact — the invocation +identity, the instruction identity, the approved source, its digest and that +successful admission — before the Component renders them. + +**The catalog is not one of them.** What a document may write is a public +question with a public answer, and canonical core owns both, so `Plan.md` writes +the same `` any document writes and binds the vocabulary +directly into every authorship prompt. Its retention is core's: one +`syntax_catalog` observation per occurrence, retaining exactly +`{ catalog: string }`, hostile-parsed on continuation so a resumed authorship is +shown the vocabulary the run actually showed it rather than one rebuilt from a +tree that has moved. `` retains exactly `{ instruction }` beside it, +so the catalog and the question are two records that can be read and reconciled +independently, and a missing, additional or mistyped member in either refuses +before authorship begins. Whether the placement is durable is carried across that boundary rather than re-derived, because `` is the last thing that sees the public @@ -996,4 +1008,4 @@ neither observation never interpreted what it wrote. | PO16 | An ordinary failure | A journal-backed invocation that fails for its own reason — a failed turn, with neither a secret rejection nor a write failure — exits non-zero, delivers no source and no artifact, completes teardown, and leaves a file whose every entry parses and whose bytes are exactly those entries re-serialized: no append failed, so there is no partial or unterminated trailing record | | PO13 | A failed destination | A consumer that fails while a turn is live cancels that turn, waits for every owned teardown, attempts no artifact sink, keeps the bytes stderr accepted, and uses the exact progress-failure diagnostic | | PO14 | Ordering is unchanged | Cancellation, teardown failure, final validation refusal, the `--output` refusal and a successful delivery all keep their order, and no phase claims an artifact was delivered | -| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is built exactly once, from private ``, after Preparing; continuation restores that snapshot without rebuilding it | +| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is observed exactly once, through public ``, after Preparing; continuation restores that observation without rebuilding it | From 25680c955bc2cbcac09bc9c959c9b44ed6c4bbad Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 22:23:46 -0400 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Give=20protected?= =?UTF-8?q?=20and=20bundled=20components=20their=20own=20catalog=20origins?= =?UTF-8?q?=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entries in the catalog said something untrue, each by borrowing a neighbouring origin kind. `` reported as `{ kind: "registered", reserved: true }`, and rendered as "reserved registration". A reserved registration is a *host* installing something under a name it wants kept: it can be absent from another run, replaced by a different host, or refused when two hosts claim it. None of that is true of a name canonical core owns, so a reader deciding whether they could supply `Syntax` themselves got exactly the wrong answer. It now reports `{ kind: "protected", origin }` and renders as "protected component", in the structured entry, the rendered Markdown and `inspectComponent` alike. A workflow-bundle member reported as `{ kind: "repository", path }`, which reads as a file the reader could edit. It is the exact blob `sourceHash` names, fixed when the run was defined. It now reports `{ kind: "workflow", path, sourceHash }` and renders the abbreviated object id beside the path. Category placement keyed off the `repository` kind, so the new kind is named there too — a bundle member is the run author's own Markdown and stays under user-provided. Both are additions to a closed set, so the catalog is version 2 rather than a silent widening of version 1: a version-1 reader was promised those origins were all of them, and neither emitting an unknown kind nor reusing a neighbour keeps that promise. Two durable shapes are now documented rather than merely implemented. `syntax_catalog` joins the journal effect table with its exact name and closed `{ catalog: string }` payload, and the `import_component` protocol gains `{ kind: "protected" }` — closed on that one member, because a protected component has no path, no origin to look up and no implementation to serialize. Its replay behavior is stated: the running execution supplies the implementation it built, and an execution that built none refuses rather than resolving the name again, which would run whatever is offered under that name today. The evidence tier moves off `SC` and `SL`, which already name Sample component and Own-scope context updates, to `SYN`. SY19, SY20 and the Evaluate clause of SY21 move to #713. They describe what `` does with a narrower catalog, and #759 installs one nowhere; worse, `` is not in the generated-XMD pinned identity table, so it cannot be invoked inside a fragment until #713 admits it. What #759 owes is the seam, and SYN25b proves it: a fixed narrower observation answers with exactly the catalog it was handed and adds nothing of its own. --- architecture.md | 26 ++- packages/cli/tests/support/plan-harness.ts | 2 +- packages/cli/tests/syntax-cli.test.ts | 19 +- packages/core/src/components/protected.ts | 15 +- packages/core/src/inspect.ts | 88 +++++++--- packages/core/src/syntax-markdown.ts | 16 ++ packages/core/src/types.ts | 25 ++- packages/core/tests/syntax-catalog.test.ts | 6 +- packages/core/tests/syntax-component.test.ts | 165 +++++++++++++----- .../core/tests/syntax-loaded-copy.test.ts | 6 +- .../tests/cross-package-resolution.test.ts | 4 + scripts/tests/cli-npm-bin.test.ts | 7 +- scripts/tests/plan-component-compiled.test.ts | 7 +- specs/executable-mdx-spec.md | 52 ++++++ 14 files changed, 334 insertions(+), 104 deletions(-) diff --git a/architecture.md b/architecture.md index 5acdf97b..a23b85ec 100644 --- a/architecture.md +++ b/architecture.md @@ -3691,9 +3691,27 @@ canonical expansion, so an implementation another loaded copy created — which an ordinary arrangement, because a component can be loaded from disk beside its own copy — has no body here and no answer to give. -The durable record follows the ordinary rules. Each occurrence claims the -identity this execution minted, performs one `syntax_catalog` observation, and -retains exactly `{ catalog: string }`. A continuation hostile-parses that record +**It says so in the catalog.** A protected component reports its own origin +kind, `protected`, rather than borrowing `registered` with `reserved: true`. The +two answer a reader's actual question — *could I supply this name myself?* — +oppositely: a reserved registration is a host installing something under a name +it wants kept, so it can be absent from another run, replaced by a different +host, or refused when two hosts claim it, and none of that is true here. A +workflow bundle member gained its own kind for the same reason: reported as a +`repository` path it read as a file the reader could edit, when it is the exact +blob `sourceHash` names, fixed when the run was defined. Both are why the catalog +is version 2 rather than an addition to version 1 — a version-1 reader was +promised a closed set of origins, and the honest fix adds to that set. + +The durable record follows the ordinary rules. Selection itself records +`{ kind: "protected" }` and nothing else: there is no path, no origin to look up +and no implementation to serialize, so the record says only which tier answered +and replay asks the running execution for the implementation it built. An +execution that built none refuses rather than resolving the name again, because a +replay that fell back to the ordinary tiers would run whatever is offered under +that name today. Each occurrence then claims the identity this execution minted, +performs one `syntax_catalog` observation, and retains exactly +`{ catalog: string }`. A continuation hostile-parses that record and hands the same text back without consulting the filesystem, the registry, the bundle, the host or the lexical observation again; a missing, additional or mistyped member is stale input rather than a component failure, and refuses @@ -3938,7 +3956,7 @@ Status is measured against main. | Construct | Does | Status | | --- | --- | --- | | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same catalog for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | -| `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, no props, and a text component: the bare form emits the catalog and the ordinary `as` captures the same text and emits nothing, while a paired spelling or an authored prop refuses before any observation. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the catalog says is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile — and it is carried lexically on canonical core's expansion authority rather than through any context. Each occurrence claims the identity the execution minted, performs one `syntax_catalog` observation, and retains exactly `{ catalog: string }`; a continuation hostile-parses that record and restores the catalog the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled observation completes its teardown and commits nothing. It carries no authority at all: a component named in a catalog is neither registered, resolved nor authorized by being named | built on this stack; the narrower observation a trusted evaluation boundary installs for its subtree is the seam #713 fills | +| `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, no props, and a text component: the bare form emits the catalog and the ordinary `as` captures the same text and emits nothing, while a paired spelling or an authored prop refuses before any observation. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the catalog says is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile — and it is carried lexically on canonical core's expansion authority rather than through any context. Each occurrence claims the identity the execution minted, performs one `syntax_catalog` observation, and retains exactly `{ catalog: string }`; a continuation hostile-parses that record and restores the catalog the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled observation completes its teardown and commits nothing. It reports itself under its own catalog origin kind, `protected`, never as a reserved registration, and selection records `{ kind: "protected" }` alone — replay asks this execution for the implementation it built rather than resolving the name again. It carries no authority at all: a component named in a catalog is neither registered, resolved nor authorized by being named | built on this stack; the narrower observation a trusted evaluation boundary installs for its subtree is the seam #713 fills | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft and every failed check's structured findings — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | | `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 789bfda8..ac5f8fc8 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -431,7 +431,7 @@ export function* planDeclarationHarness(options: { * look for without depending on the whole run profile being assembled. */ export const CASE_CATALOG: SyntaxCatalog = { - version: 1, + version: 2, categories: [ { kind: "structural", entries: [] }, { diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index cc5f5877..950478c9 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -66,8 +66,8 @@ function parseCatalog(text: string): SyntaxCatalog { } const version = Reflect.get(parsed, "version"); const categories = Reflect.get(parsed, "categories"); - if (version !== 1 || !Array.isArray(categories) || categories.length !== 3) { - throw new Error("the catalog is not the version-1 shape"); + if (version !== 2 || !Array.isArray(categories) || categories.length !== 3) { + throw new Error("the catalog is not the version-2 shape"); } return { version, categories: readCategories(categories) }; } @@ -107,7 +107,7 @@ function names(entries: readonly { name: string }[]): string[] { /** One built-in entry carrying `props`, for a renderer row that supplies its own. */ function catalogWith(props: PropsSchema): SyntaxCatalog { return { - version: 1, + version: 2, categories: [ { kind: "structural", entries: [] }, { @@ -189,11 +189,8 @@ describe("Tier SX — the run profile the command describes", () => { if (entry === undefined || entry.kind !== "component" || entry.inspectability !== "complete") { throw new Error("the catalog describes without a contract"); } - expect(entry.origin).toEqual({ - kind: "registered", - origin: "@executablemd/core", - reserved: true, - }); + expect(entry.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); + expect(entry.sourceKind).toBe("protected"); expect(entry.forms).toEqual(["self-closing"]); expect(entry.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); expect(entry.captures).toEqual([]); @@ -471,7 +468,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); - it("SX10: writes markdown by default and version-1 JSON with --json", function* () { + it("SX10: writes markdown by default and version-2 JSON with --json", function* () { yield* useWorkspace(WORKSPACE, function* (cwd) { const markdown = yield* runCli(["syntax", "--include", "first"], { cwd }).expect(); expect(markdown.stdout).toContain("## Built-in structural syntax"); @@ -480,7 +477,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources const json = yield* runCli(["syntax", "--json", "--include", "first"], { cwd }).expect(); const catalog = parseCatalog(json.stdout); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(names(catalog.categories[2].entries)).toEqual(["Shared"]); }); }); @@ -561,7 +558,7 @@ describe( expect(piped).toBe(redirected); const catalog = parseCatalog(piped); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(names(catalog.categories[2].entries)).toContain("ZBeyondTheBuffer"); expect(piped.lastIndexOf(`"ZBeyondTheBuffer"`)).toBeGreaterThan(PIPE_BUFFER); }); diff --git a/packages/core/src/components/protected.ts b/packages/core/src/components/protected.ts index 529e0123..33031f26 100644 --- a/packages/core/src/components/protected.ts +++ b/packages/core/src/components/protected.ts @@ -75,15 +75,18 @@ export function protectedComponent(name: string): ProtectedComponent | undefined /** * The origin a protected component reports. * - * `reserved` is the catalog's word for a name a document cannot take back, which - * is what this tier makes true of it. No new origin kind: a reader learns where - * the component came from and that nothing shadows it, from the two fields the - * schema already has. + * Its own kind, because reusing `registered` with `reserved: true` said + * something untrue. A reserved registration is a *host* installing a component + * under a name it wants kept, so it can be absent from another execution, + * replaced by a different host, or refused when two hosts claim it. A protected + * component is core's own declaration: present in every execution, supplied by + * no registry, and unable to be registered at all. Reporting one as the other + * told a reader the name was a host's to take. */ export function protectedOrigin( component: ProtectedComponent, -): Extract { - return { kind: "registered", origin: component.origin, reserved: true }; +): Extract { + return { kind: "protected", origin: component.origin }; } /** A protected name a host, a bundle or a registration tried to claim. */ diff --git a/packages/core/src/inspect.ts b/packages/core/src/inspect.ts index a8a88247..12b8e4c0 100644 --- a/packages/core/src/inspect.ts +++ b/packages/core/src/inspect.ts @@ -158,6 +158,19 @@ export type ComponentInfo = props: PropsSchema; returns?: ReturnsSchema; } & DescribedContract) + /** + * A component canonical core claims the name of. + * + * Its own `kind` for the same reason its origin has one: a caller asking what + * `Syntax` is needs to learn that no registry supplies it and none can, which + * `registered` said the opposite of. + */ + | ({ + kind: "protected"; + origin: ComponentOrigin; + props: PropsSchema; + returns?: ReturnsSchema; + } & DescribedContract) | ({ kind: "markdown"; origin: ComponentOrigin; @@ -210,7 +223,7 @@ export function* inspectComponent(options: InspectComponentOptions): Operation; - readonly sourceKind: "registered" | "markdown" | "declared-markdown"; + /** + * What kind of thing supplied the contract above. + * + * `protected` and `workflow-markdown` are version 2's additions. Both were + * previously folded into a neighbour — `registered` and `markdown` — which + * made a catalog reader unable to tell core's own component from a host's + * registration, or a pinned bundle member from a file on disk. + */ + readonly sourceKind: + | "registered" + | "protected" + | "markdown" + | "workflow-markdown" + | "declared-markdown"; readonly inspectability: "complete"; readonly forms: readonly ("self-closing" | "paired")[]; readonly props: PropsSchema; @@ -499,7 +533,13 @@ export function* inspectSyntax(options: InspectSyntaxOptions): Operation, - sourceKind: "registered" | "markdown" | "declared-markdown", + sourceKind: CompleteComponentSyntaxEntry["sourceKind"], contract: CompleteContract, ): CompleteComponentSyntaxEntry { return { diff --git a/packages/core/src/syntax-markdown.ts b/packages/core/src/syntax-markdown.ts index 928aae20..13af7b9a 100644 --- a/packages/core/src/syntax-markdown.ts +++ b/packages/core/src/syntax-markdown.ts @@ -227,12 +227,28 @@ function describeOrigin(origin: ComponentOrigin): string { if (origin.kind === "registered") { return `${code(origin.origin)} (${origin.reserved ? "reserved registration" : "registered default"})`; } + if (origin.kind === "protected") { + // Not "reserved registration": a reader deciding whether they can supply + // this name themselves gets the opposite answer from the two phrases. + return `${code(origin.origin)} (protected component)`; + } + if (origin.kind === "workflow") { + // The object id as well as the path, so this cannot be read as a file the + // reader could edit. Abbreviated the way a commit is: enough to compare, + // short enough to sit in a table cell. + return `${code(origin.path)} (workflow bundle, ${code(abbreviate(origin.sourceHash))})`; + } if (origin.kind === "declared-markdown") { return `${code(origin.origin)} (declared Markdown)`; } return `structural syntax (${code(origin.construct)})`; } +/** A blob id, shortened for a table cell but left whole when it is already short. */ +function abbreviate(sourceHash: string): string { + return sourceHash.length > 12 ? sourceHash.slice(0, 12) : sourceHash; +} + function code(text: string): string { return `\`${text}\``; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index dd1f5423..b5014901 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -328,6 +328,29 @@ export type ComponentOrigin = | { kind: "structural"; construct: string } | { kind: "repository"; path: string } | { kind: "registered"; origin: string; reserved: boolean } + /** + * A component canonical core claims the name of, ahead of every host and + * author tier (`components/protected.ts`). + * + * Its own kind rather than a reserved registration, because it is not one: a + * reserved registration is a host installing something under a name this + * execution happens to protect, and it can be absent, replaced or refused at + * registration. This is core's own declaration, present in every execution, + * and no registry supplies it. Reporting it as a registration told a reader + * the name could be re-registered, which is exactly what it cannot be. + */ + | { kind: "protected"; origin: string } + /** + * A component a workflow definition is closed over, at the canonical + * repository-relative path the blob holds inside the pinned commit. + * + * Distinct from `repository` because the two answer differently to the only + * question a reader has about them: a repository candidate is whatever that + * path holds now, and a bundle member is the exact blob `sourceHash` names, + * fixed when the run was defined. Reporting a bundled component as a + * repository one said a mutable path decided it. + */ + | { kind: "workflow"; path: string; sourceHash: string } /** * Exact Markdown a trusted host declared to this environment. It names the * first-party asset the bytes came from, never a path a repository could @@ -353,7 +376,7 @@ export type ComponentSelection = | { kind: "protected"; component: ProtectedComponent; - origin: Extract; + origin: Extract; } | { kind: "registered"; definition: FunctionComponentDefinition; origin: ComponentOrigin } | { kind: "repository"; path: string } diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 19a4095a..2d6d661b 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -237,10 +237,10 @@ const DOCUMENTED = [ ].join("\n"); describe("Tier SY: the versioned shape", () => { - it("SY1: reports version 1 and the three categories in a fixed order", function* () { + it("SY1: reports version 2 and the three categories in a fixed order", function* () { const catalog = yield* catalogFor({ components: { kind: "directory" } }, ["components"]); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(catalog.categories.map((category) => category.kind)).toEqual([ "structural", "built-in", @@ -321,7 +321,7 @@ describe("Tier SY: structural vocabulary", () => { const catalog = yield* catalogFor({}, []); const entries = structural(catalog); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(find(entries, "Switch")).toEqual({ kind: "structural", name: "Switch", diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 5749d60a..3e418058 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -1,5 +1,5 @@ /** - * Tier SC — ``, the component canonical core owns. + * Tier SYN — ``, the component canonical core owns. * * What a document may write here is a public question, and this is the public * answer: the catalog for the site the element was written at, in the words @@ -46,6 +46,7 @@ import { inspectComponent, inspectSyntax } from "../src/inspect.ts"; import { validateDocumentStructure } from "../src/document-validation.ts"; import { registerComponents } from "../src/components/registration.ts"; import { selectComponent } from "../src/components/select.ts"; +import { installedBundle } from "../src/components/bundle.ts"; import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; import { fixedCatalogObservation } from "../src/syntax-observation.ts"; @@ -63,7 +64,7 @@ const DESCRIPTION = /** A catalog with one built-in entry per name, for a case that needs a marker. */ function catalogOf(...names: readonly string[]): SyntaxCatalog { return { - version: 1, + version: 2, categories: [ { kind: "structural", entries: [] }, { @@ -187,8 +188,8 @@ function useWorkingDirectory(body: (dir: string) => Operation): Operation< }); } -describe("Tier SC — what one occurrence answers", () => { - it("SC1: the bare form renders the catalog once, and `as` binds the same text", function* () { +describe("Tier SYN — what one occurrence answers", () => { + it("SYN1: the bare form renders the catalog once, and `as` binds the same text", function* () { const { installation } = stating(catalogOf("Marker")); const bare = yield* run("\n", [installation]); expect(String(bare)).toContain("### ``"); @@ -206,7 +207,7 @@ describe("Tier SC — what one occurrence answers", () => { expect(String(captured).split("### ``").length - 1).toBe(1); }); - it("SC2: it renders exactly what the shared Markdown renderer produces", function* () { + it("SYN2: it renders exactly what the shared Markdown renderer produces", function* () { const catalog = catalogOf("Marker", "Other"); const { installation } = stating(catalog); const bare = yield* run('{catalog}', [installation]); @@ -216,7 +217,7 @@ describe("Tier SC — what one occurrence answers", () => { expect(String(bare)).toBe(renderSyntaxMarkdown(catalog)); }); - it("SC3: a paired spelling and an authored prop refuse before any observation", function* () { + it("SYN3: a paired spelling and an authored prop refuse before any observation", function* () { const paired = stating(catalogOf("Marker")); expect(yield* refusal(run("content\n", [paired.installation]))).toContain( "written self-closing", @@ -235,7 +236,7 @@ describe("Tier SC — what one occurrence answers", () => { expect(accepted.calls.count).toBe(1); }); - it("SC4: one occurrence observes once, two observe independently, a binding observes neither again", function* () { + it("SYN4: one occurrence observes once, two observe independently, a binding observes neither again", function* () { const one = stating(catalogOf("Marker")); yield* run('{catalog}{catalog}{catalog}', [one.installation]); expect(one.calls.count).toBe(1); @@ -251,8 +252,8 @@ describe("Tier SC — what one occurrence answers", () => { }); }); -describe("Tier SC — the name canonical core owns", () => { - it("SC5: a repository Syntax.md, Syntax.ts and directory candidate never win", function* () { +describe("Tier SYN — the name canonical core owns", () => { + it("SYN5: a repository Syntax.md, Syntax.ts and directory candidate never win", function* () { yield* useWorkingDirectory(function* (dir) { yield* writeTextFile(join(dir, "Syntax.md"), "a repository catalog\n"); yield* writeTextFile(join(dir, "Nearby.md"), "a nearby repository component\n"); @@ -270,22 +271,22 @@ describe("Tier SC — the name canonical core owns", () => { }); }); - it("SC6: selection reports the protected tier ahead of every other", function* () { + it("SYN6: selection reports the protected tier ahead of every other", function* () { yield* useWorkingDirectory(function* (dir) { yield* writeTextFile(join(dir, "Syntax.md"), "a repository catalog\n"); const selected = yield* selectComponent(SYNTAX_COMPONENT, { includes: [dir] }); expect(selected.kind).toBe("protected"); - // The origin is core's, and reserved — the catalog's word for a name a - // document cannot take back. + // Its own origin kind. Not a reserved registration: that is a host + // installing something under a name, which can be absent, replaced or + // refused, and none of those is true of a name core owns. expect(selected.kind === "protected" ? selected.origin : undefined).toEqual({ - kind: "registered", + kind: "protected", origin: "@executablemd/core", - reserved: true, }); }); }); - it("SC7: an ordinary and a reserved registration named Syntax are both refused", function* () { + it("SYN7: an ordinary and a reserved registration named Syntax are both refused", function* () { const refused = yield* refusal( scoped(function* () { yield* registerComponents([ @@ -322,7 +323,7 @@ describe("Tier SC — the name canonical core owns", () => { expect(reservedRefusal).toContain("canonical core owns that name"); }); - it("SC8: the refused batch registers nothing, and an adjacent registration still works", function* () { + it("SYN8: the refused batch registers nothing, and an adjacent registration still works", function* () { const good = { name: "Adjacent", origin: "@executablemd/test", @@ -364,7 +365,7 @@ describe("Tier SC — the name canonical core owns", () => { ).toBe("registered"); }); - it("SC9: a host that declares Markdown called Syntax is refused before the root import", function* () { + it("SYN9: a host that declares Markdown called Syntax is refused before the root import", function* () { const source = "a declared catalog\n"; const declaration: DeclaredMarkdownComponent = { name: "Syntax", @@ -393,7 +394,7 @@ describe("Tier SC — the name canonical core owns", () => { ); }); - it("SC10: a workflow bundle member called Syntax is refused before the root import", function* () { + it("SYN10: a workflow bundle member called Syntax is refused before the root import", function* () { const bundled = { name: "Syntax", path: "components/Syntax.md", @@ -418,7 +419,7 @@ describe("Tier SC — the name canonical core owns", () => { }); }); -describe("Tier SC — what the chain may and may not do", () => { +describe("Tier SYN — what the chain may and may not do", () => { /** A handler that answers `Syntax` with whatever `answer` produces. */ function answering( answer: (real: ImportedDefinition) => ImportedDefinition, @@ -440,7 +441,7 @@ describe("Tier SC — what the chain may and may not do", () => { }; } - it("SC11: ordinary delegation reaches canonical Syntax", function* () { + it("SYN11: ordinary delegation reaches canonical Syntax", function* () { const seen: string[] = []; const observing: ExecutionInstallation = { *install() { @@ -461,7 +462,7 @@ describe("Tier SC — what the chain may and may not do", () => { expect(seen).toContain(SYNTAX_COMPONENT); }); - it("SC12: a handler that answers, substitutes, mutates or copies runs no replacement", function* () { + it("SYN12: a handler that answers, substitutes, mutates or copies runs no replacement", function* () { const replacement: FunctionComponent = function* () { return "a replaced catalog"; }; @@ -497,7 +498,7 @@ describe("Tier SC — what the chain may and may not do", () => { } }); - it("SC13: a handler that redirects the name, or delegates twice, answers nothing", function* () { + it("SYN13: a handler that redirects the name, or delegates twice, answers nothing", function* () { const redirecting: ExecutionInstallation = { *install() { yield* Component.around( @@ -540,7 +541,7 @@ describe("Tier SC — what the chain may and may not do", () => { ).toBeTruthy(); }); - it("SC14: a deliberate middleware refusal stays a refusal", function* () { + it("SYN14: a deliberate middleware refusal stays a refusal", function* () { const refusing: ExecutionInstallation = { *install() { yield* Component.around( @@ -563,7 +564,7 @@ describe("Tier SC — what the chain may and may not do", () => { expect(calls.count).toBe(0); }); - it("SC15: a document-authored context and a look-alike observation change nothing", function* () { + it("SYN15: a document-authored context and a look-alike observation change nothing", function* () { // Nothing a document writes reaches the observation: it is not addressed by // name. The strongest thing an authored document can do is register and // bind, and the catalog is unchanged by both. @@ -580,8 +581,8 @@ describe("Tier SC — what the chain may and may not do", () => { }); }); -describe("Tier SC — the site the catalog describes", () => { - it("SC16: the derived catalog reports this execution's own includes and registry", function* () { +describe("Tier SYN — the site the catalog describes", () => { + it("SYN16: the derived catalog reports this execution's own includes and registry", function* () { yield* useWorkingDirectory(function* (dir) { yield* writeTextFile(join(dir, "Local.md"), "a local component\n"); // No host contribution: canonical core derives the catalog from the @@ -591,11 +592,89 @@ describe("Tier SC — the site the catalog describes", () => { // And it describes itself, once, with the approved description. expect(output).toContain("### ``"); expect(output).toContain(DESCRIPTION); - expect(output).toContain("`@executablemd/core` (reserved registration)"); + // Its own provenance, not a registration's. A reader deciding whether + // they could supply this name themselves gets the opposite answer from + // the two phrases, so the catalog must not print the other one. + expect(output).toContain("`@executablemd/core` (protected component)"); + expect(output).not.toContain("reserved registration"); }); }); - it("SC17: a workflow root observes its own bundle without running a member", function* () { + it("SYN27: the catalog reports a protected component as protected, not registered", function* () { + const { installation } = stating(catalogOf("Marker")); + const catalog = yield* scoped(function* () { + yield* executeInstalled( + { + ...retainedSource(ROOT_PATH, "\n"), + stream: new InMemoryStream(), + includes: [], + }, + [installation], + ); + return yield* inspectSyntax({ includes: [] }); + }); + expect(catalog.version).toBe(2); + + // Built-in: the second category, where a reader indexes for it. + const entry = catalog.categories[1].entries.find((candidate) => candidate.name === "Syntax"); + if (entry === undefined) { + throw new Error("expected the catalog to describe "); + } + // The structured origin, which is what a machine reader switches on. + expect(entry.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); + expect(entry.sourceKind).toBe("protected"); + + // And `inspectComponent` agrees, so one name and the whole environment + // cannot describe the same component two ways. + const info = yield* scoped(function* () { + return yield* inspectComponent({ name: "Syntax", includes: [] }); + }); + if (info.kind !== "protected") { + throw new Error(`expected a protected component, got ${info.kind}`); + } + expect(info.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); + }); + + it("SYN28: a bundled component is reported as pinned, not as a repository file", function* () { + const sourceHash = "1".repeat(40); + const bundle = { + components: [ + { + name: "Bundled", + path: "components/Bundled.md", + sourceHash, + content: "a bundled component\n", + }, + ], + }; + const output = String(yield* run("\n", [{ bundle }])); + + // The path alone would read as a file the reader could edit; the blob id is + // what says this is the exact source the run was defined against. + expect(output).toContain("`components/Bundled.md` (workflow bundle, `111111111111`)"); + + const catalog = yield* scoped(function* () { + const registry = yield* Component.operations.registry; + const workflow = installedBundle([bundle], registry); + if (workflow === undefined) { + throw new Error("expected the bundle to install"); + } + return yield* inspectSyntax({ includes: [], workflow }); + }); + // User-provided: the third category. + const entry = catalog.categories[2].entries.find((candidate) => candidate.name === "Bundled"); + if (entry === undefined || entry.inspectability !== "complete") { + throw new Error("expected the catalog to describe completely"); + } + expect(entry.origin).toEqual({ + kind: "workflow", + path: "components/Bundled.md", + sourceHash, + }); + expect(entry.sourceKind).toBe("workflow-markdown"); + }); + + it("SYN17: a workflow root observes its own bundle without running a member", function* () { const entered: string[] = []; const bundle = { components: [ @@ -620,7 +699,7 @@ describe("Tier SC — the site the catalog describes", () => { ).toBe(false); }); - it("SC18: a declared Markdown component's own body observes the site it inherited", function* () { + it("SYN18: a declared Markdown component's own body observes the site it inherited", function* () { const source = ['', "policy sees {catalog}", ""].join("\n"); const declaration: DeclaredMarkdownComponent = { name: "Policy", @@ -637,8 +716,8 @@ describe("Tier SC — the site the catalog describes", () => { }); }); -describe("Tier SC — the record one occurrence keeps", () => { - it("SC19: the retained payload is closed on exactly { catalog }", function* () { +describe("Tier SYN — the record one occurrence keeps", () => { + it("SYN19: the retained payload is closed on exactly { catalog }", function* () { const stream = new InMemoryStream(); yield* run("\n", [stating(catalogOf("Marker")).installation], stream); const [observation] = observations(yield* stream.readAll()); @@ -650,7 +729,7 @@ describe("Tier SC — the record one occurrence keeps", () => { expect(typeof value.catalog).toBe("string"); }); - it("SC20: a continuation restores the catalog after the environment moves, and asks nothing", function* () { + it("SYN20: a continuation restores the catalog after the environment moves, and asks nothing", function* () { const first = new InMemoryStream(); const before = String( yield* run("\n", [stating(catalogOf("Before")).installation], first), @@ -676,7 +755,7 @@ describe("Tier SC — the record one occurrence keeps", () => { ).toContain("### ``"); }); - it("SC21: a missing, extra or wrong-typed retained payload refuses before output or binding", function* () { + it("SYN21: a missing, extra or wrong-typed retained payload refuses before output or binding", function* () { const cases: [string, (value: Json) => Json][] = [ ["the member is missing", () => ({})], ["an unknown member was added", (value) => ({ ...Object(value), extra: true })], @@ -697,7 +776,7 @@ describe("Tier SC — the record one occurrence keeps", () => { } }); - it("SC22: a cancelled observation tears down and commits no catalog", function* () { + it("SYN22: a cancelled observation tears down and commits no catalog", function* () { const teardown: string[] = []; const stream = new InMemoryStream(); const hanging: ExecutionInstallation = { @@ -728,8 +807,8 @@ describe("Tier SC — the record one occurrence keeps", () => { }); }); -describe("Tier SC — observation is never authority", () => { - it("SC23: a catalog naming a component neither registers nor resolves it", function* () { +describe("Tier SYN — observation is never authority", () => { + it("SYN23: a catalog naming a component neither registers nor resolves it", function* () { // The strongest form: the trusted host itself states a catalog naming a // component nothing supplies. const { installation } = stating(catalogOf("Phantom")); @@ -743,7 +822,7 @@ describe("Tier SC — observation is never authority", () => { expect((yield* selectComponent("Phantom", { includes: [] })).kind).toBe("unresolved"); }); - it("SC24: the component is described identically by inspection and by validation", function* () { + it("SYN24: the component is described identically by inspection and by validation", function* () { const catalog = yield* inspectSyntax({ includes: [] }); const entry = catalog.categories[1].entries.find((candidate) => candidate.name === "Syntax"); expect(entry).toBeDefined(); @@ -751,11 +830,7 @@ describe("Tier SC — observation is never authority", () => { expect(entry?.forms).toEqual(["self-closing"]); expect(entry?.returnMode).toBe("text"); expect(entry?.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); - expect(entry?.origin).toEqual({ - kind: "registered", - origin: "@executablemd/core", - reserved: true, - }); + expect(entry?.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); // Exactly one entry, in exactly one category. const everywhere = catalog.categories.flatMap((category) => category.entries.filter((candidate) => candidate.name === "Syntax"), @@ -763,7 +838,7 @@ describe("Tier SC — observation is never authority", () => { expect(everywhere.length).toBe(1); const described = yield* inspectComponent({ name: "Syntax", includes: [] }); - expect(described.kind).toBe("registered"); + expect(described.kind).toBe("protected"); // Validation reads the same declaration, so a paired spelling is invalid // before anything runs and the self-closing one is valid. @@ -789,7 +864,7 @@ describe("Tier SC — observation is never authority", () => { * Installing it for an evaluation subtree is #713's; that the observation is * the catalog and nothing more is this. */ - it("SC25b: a narrowed observation answers with exactly the catalog it was given", function* () { + it("SYN25b: a narrowed observation answers with exactly the catalog it was given", function* () { const narrowed = catalogOf("Admitted"); const observation = fixedCatalogObservation(narrowed); expect(yield* observation.observe()).toBe(renderSyntaxMarkdown(narrowed)); @@ -798,7 +873,7 @@ describe("Tier SC — observation is never authority", () => { expect(yield* observation.observe()).not.toContain("### ``"); }); - it("SC25: an execution that carries no observation refuses rather than inventing one", function* () { + it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () { // `execute()` driven directly still carries one, so the case that has none // is an expansion driven outside an execution — which is what a component // reaching for a catalog with nothing established would meet. diff --git a/packages/core/tests/syntax-loaded-copy.test.ts b/packages/core/tests/syntax-loaded-copy.test.ts index 9c80fdd7..a93e059f 100644 --- a/packages/core/tests/syntax-loaded-copy.test.ts +++ b/packages/core/tests/syntax-loaded-copy.test.ts @@ -1,5 +1,5 @@ /** - * Tier SL — a protected implementation from a second loaded copy answers for + * Tier SYN — a protected implementation from a second loaded copy answers for * nothing. * * A component can be loaded from disk beside its own copy of core: that is what @@ -163,8 +163,8 @@ function* refusal(operation: Operation): Operation { throw new Error("expected the operation to be refused"); } -describe("Tier SL — a separately loaded protected implementation", () => { - it("SL1: an implementation another copy built answers for nothing here", function* () { +describe("Tier SYN — a separately loaded protected implementation", () => { + it("SYN26: an implementation another copy built answers for nothing here", function* () { const copy = yield* useSeparateCopy(); // An installation the other copy performed: it minted the domain, built the diff --git a/packages/test-agent/tests/cross-package-resolution.test.ts b/packages/test-agent/tests/cross-package-resolution.test.ts index dca00fa0..235ca324 100644 --- a/packages/test-agent/tests/cross-package-resolution.test.ts +++ b/packages/test-agent/tests/cross-package-resolution.test.ts @@ -200,8 +200,12 @@ function describeOrigin(info: ComponentInfo): string { return `structural:${info.origin.construct}`; case "registered": return `registered:${info.origin.origin}${info.origin.reserved ? " (reserved)" : ""}`; + case "protected": + return `protected:${info.origin.origin}`; case "repository": return `repository:${info.origin.path}`; + case "workflow": + return `workflow:${info.origin.path}@${info.origin.sourceHash}`; case "declared-markdown": return `declared-markdown:${info.origin.origin}`; } diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 43e8b876..6a8c46f4 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -235,11 +235,8 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // that lost the protected tier would either omit it or list it twice. const syntax = entries.filter((entry: { name?: string }) => entry?.name === "Syntax"); expect(syntax).toHaveLength(1); - expect(syntax[0].origin).toEqual({ - kind: "registered", - origin: "@executablemd/core", - reserved: true, - }); + expect(syntax[0].origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); + expect(syntax[0].sourceKind).toBe("protected"); expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 462d5a15..cbd3a12c 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -106,11 +106,8 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => // that lost it would describe no catalog component at all. const syntax = entries.filter((entry: { name?: string }) => entry?.name === "Syntax"); expect(syntax).toHaveLength(1); - expect(syntax[0].origin).toEqual({ - kind: "registered", - origin: "@executablemd/core", - reserved: true, - }); + expect(syntax[0].origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); + expect(syntax[0].sourceKind).toBe("protected"); expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 8bdd954a..4b97e113 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2722,6 +2722,17 @@ Seeing a component in a catalog grants nothing. It neither registers, resolves nor authorizes that component: what a name means is still this section's decision, and what may run is still the execution's. +**The catalog says where it came from.** A protected component is reported under +the catalog origin kind `protected`, carrying the canonical core origin — never +as a reserved registration, which is a *host's* claim under a name and can be +absent, replaced or refused where this cannot. A workflow-bundle member is +reported under the kind `workflow`, carrying its canonical repository-relative +path **and** the blob's own object id, so it stays distinguishable from a +repository candidate, which is whatever that path holds now. Both kinds are +additions, so the catalog is **version 2**: a version-1 reader was promised a +closed set of origins, and neither emitting an unknown kind nor reusing a +neighbouring one would keep that promise. Nothing else about the shape changed. + **Each occurrence observes once.** It claims the durable identity the execution minted for it, performs one `syntax_catalog` durable observation, and retains exactly `{ catalog: string }`. On continuation that record is parsed as a closed @@ -3575,6 +3586,26 @@ A component the workflow definition is closed over records its own shape: "content": "discovered.\n" } } ``` +A component canonical core claims the name of (§5.3.1) records the fact and +nothing else: + +```json +{ "type": "import_component", "name": "Syntax" } +{ "status": "ok", "value": { "kind": "protected" } } +``` + +The record is closed on that single member because there is nothing else that +would be true to write. A protected component has no path to record, no origin +to look up, and no implementation to serialize: it is core's own declaration, +present in every execution rather than supplied by anything this one installed. +So the record says only *which tier answered*, and replay asks the running +execution for the implementation it built for that name. An execution that built +none refuses rather than resolving the name again — a replay that fell back to +the ordinary tiers would run whatever a repository, a registry or a host is +offering under that name today, which is the substitution the tier exists to +prevent. A record carrying any member beside `kind`, or a `kind` this protocol +does not define, is stale input. + One journal entry per component, whatever it resolved to. A repository entry captures both *which file was found* (path) and *what was in it* (content); a registration entry captures the origin that named it, because a function cannot @@ -9766,6 +9797,7 @@ trusted-host events may have no authored source. | Resolve components (glob) | `glob` | `resolve:{dir}` | Only when `useDurableGlobResolver` middleware is installed | | Read over HTTP | `fetch` | `fetch:{expansion id}` | Normalized request in `description.input`; status, detached headers and text body in the result (§6.18) | | Admit generated XMD | `generated_xmd` | `generated:{fragment id}` | The canonical class selection, retained roots, selected root, every selected entry as a name, identity and admitted forms, and the exact request policy in `description.input`; the admitted source, that same policy, and the identity and form of each element the fragment named in the result (workflow-workspace-spec §8.4) | +| Observe the catalog | `syntax_catalog` | `syntax_catalog:{expansion id}` | One per authored `` occurrence. The success payload is closed on exactly `{ catalog: string }` — the rendered Markdown the component returned — so a continuation restores the catalog the run actually showed without consulting the filesystem, registry, bundle, host or lexical observation again. A missing, additional or mistyped member is stale input and refuses before output or binding; a cancelled observation completes teardown and commits nothing (§5.3.1) | ### 10.2 Example journal for a multi-component document @@ -10796,6 +10828,26 @@ and renders that file's marker; `xmd run -#Section` executes `Section` of that same file, rendering its marker and not its sibling section's; and `xmd test -` keeps the test command's own path behavior. +### Tier SYN — The public `` component + +Named `SYN` rather than `SY` or `SL`, which already name the syntax catalog and +own-scope context updates. The catalog *value* is Tier SY's; this tier is the +component that observes one at an authored site. + +| # | Test | Verify | +|---|------|--------| +| SYN1–SYN4 | One occurrence | The bare form renders the catalog once and `as` binds the same text emitting nothing; a paired spelling and an authored prop refuse before any observation; two occurrences observe independently and a reused binding observes nothing again | +| SYN5–SYN10 | The name canonical core owns | A repository `Syntax.md`, `Syntax.ts` and directory candidate never win selection, with an ordinary nearby component as the positive control that repository discovery is live; ordinary and reserved registrations are refused atomically; a workflow bundle member and a host's declared Markdown are each refused at admission before the root import | +| SYN6 | The origin selection reports | Selection answers `{ kind: "protected", origin: "@executablemd/core" }` — its own kind, not a reserved registration | +| SYN11–SYN15 | The import chain | Middleware that answers, substitutes, mutates, redirects, delegates twice or reuses another import's definition cannot run a replacement; ordinary delegation reaches canonical ``; a deliberate middleware refusal stays a refusal; document-authored context and a look-alike observation change nothing | +| SYN16–SYN18 | The site described | An ordinary run reports its own includes and registry; a workflow root reports its bundle without importing or running a member; a declared Markdown component's body reports the site it inherited | +| SYN20–SYN22 | The record kept | Continuation restores the retained catalog after the environment moves and rediscovers nothing; missing, additional and wrong-typed payloads refuse before output or binding; a cancelled observation completes teardown and commits nothing | +| SYN23, SYN25b | Never authority | A catalog naming a component neither registers, resolves nor authorizes it; a fixed narrower observation answers with exactly the catalog it was handed and adds nothing — the seam `` installs through | +| SYN24 | One description | Inspection and validation describe the component identically, from one declaration | +| SYN26 | Another loaded copy | A protected implementation built by a second loaded copy answers for nothing in the active execution | +| SYN27 | Protected provenance | The catalog reports the component under the `protected` origin kind in both the structured entry and the rendered Markdown, and never as a reserved registration; `inspectComponent` agrees | +| SYN28 | Pinned provenance | A workflow-bundle component is reported at its path *and* blob object id, under the `workflow` origin kind, and stays in the user-provided category | + ### Tier SX — The `xmd syntax` command | # | Test | Verify | From 7c9f95d54f3246b00406f0bba1202359a8c693c8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 22:47:10 -0400 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9C=A8=20Let=20``=20render=20s?= =?UTF-8?q?elected=20components'=20documentation=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact catalog answers *what may I write here*. It does not answer *how do I use this one*, and an agent handed seventy entries to explain one component has to guess. So `` gains an optional `names`: which renders each selected component's catalog metadata followed by its long-form documentation, once each, in catalog order whatever order they were asked for in. `as` captures the same text. The bare form is unchanged. The documentation is a package's own. A registration bundle keeps `components.md` beside the boundary it documents, located from the owning module's URL — never the working directory and never `--include`, because a documentation set that moved with the caller would describe a different product depending on where somebody stood. `deno compile --include` embeds it, the npm build copies it, and JSR publishes the source, so all four distributions load the same bytes. The index validates rather than trusts. A level-two heading is an exact component name, text before the first documents the bundle, and level-three and deeper stay in the section they are written in so a component's own documentation can have structure. A heading that names something the package does not supply, one that appears twice, and one that is not a component name at all each refuse the whole index. Headings inside fenced blocks are examples, not sections. A component with no section is legal and renders the sentence #758 states for one. Documentation joins to metadata by name *and* owning package, so a repository `Elicit.md` never receives the built-in's prose: it has a repository origin, which names no package, and the join has nothing to match on. The observation carries two inputs now, and that is why it is an object rather than a string. *What may I write* and *what may I read about* are different questions, and a narrowing evaluation boundary answers them differently on purpose: selection reads the enclosing authoring catalog, so a nested author can be told how `` works where they may not run one, and every rendered entry states whether it is available in the current evaluation. #713 installs that boundary; this proves the seam. `xmd syntax Elicit` is the same lookup — one selection, one index, one renderer — so the command and the component cannot describe one component two ways. The compact catalog and version-2 `--json` are untouched; documentation is prose rather than a catalog member. Also reconciles the CLI help's stale version-1 claim. --- deno.json | 2 +- packages/cli/src/cli.ts | 30 ++- packages/cli/src/syntax.ts | 22 ++ packages/cli/tests/syntax-cli.test.ts | 34 ++- packages/core/mod.ts | 14 +- packages/core/src/component-documentation.ts | 73 ++++++ packages/core/src/components/Syntax.ts | 67 ++++- packages/core/src/components/components.md | 112 +++++++++ packages/core/src/documentation-index.ts | 246 +++++++++++++++++++ packages/core/src/syntax-markdown.ts | 36 +++ packages/core/src/syntax-observation.ts | 107 +++++++- packages/core/tests/syntax-component.test.ts | 97 +++++++- scripts/build-npm.ts | 15 +- 13 files changed, 830 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/component-documentation.ts create mode 100644 packages/core/src/components/components.md create mode 100644 packages/core/src/documentation-index.ts diff --git a/deno.json b/deno.json index d3e68f86..5908e276 100644 --- a/deno.json +++ b/deno.json @@ -58,7 +58,7 @@ "verify:clean": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/verify-clean.ts", "deps": "deno run --allow-all scripts/deps.ts", "deps:target": "deno run --allow-all scripts/deps-target.ts", - "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --output dist/xmd packages/cli/src/compiled.ts", + "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --include packages/core/src/components/components.md --output dist/xmd packages/cli/src/compiled.ts", "build:web": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/build-web-client.ts", "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6488b528..347cc9f4 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -128,7 +128,12 @@ import { runPlan } from "./plan.ts"; import { runUpgrade } from "./upgrade.ts"; import type { UpgradeAssembly } from "./upgrade.ts"; import { componentSearchPath, resolveTestTarget } from "./test-target.ts"; -import { renderSyntaxJson, renderSyntaxMarkdown, syntaxCatalog } from "./syntax.ts"; +import { + renderSyntaxDocumentation, + renderSyntaxJson, + renderSyntaxMarkdown, + syntaxCatalog, +} from "./syntax.ts"; import { deliverWhole } from "./stdout-delivery.ts"; import { testingExecutionHost } from "./testing-host.ts"; import type { ChildPlanDeclaration } from "./testing-host.ts"; @@ -356,12 +361,18 @@ const testConfig = object({ * `run` and `test` declare, and explicit values replace the defaults. */ const syntaxConfig = object({ + component: { + description: + "component to describe in full — `xmd syntax Elicit` renders its catalog metadata " + + "and long-form documentation instead of the compact catalog", + ...field(z.string().optional(), cli.argument()), + }, include: { description: "component search directory", ...field(z.array(z.string()), field.default(["components", "."]), field.array()), }, json: { - description: "write the catalog as version-1 JSON instead of markdown", + description: "write the catalog as version-2 JSON instead of markdown", ...field(z.boolean(), field.default(false)), }, }); @@ -2631,7 +2642,20 @@ function* dispatch( let rendered: string; try { const catalog = yield* syntaxCatalog(command.config.include); - rendered = command.config.json ? renderSyntaxJson(catalog) : renderSyntaxMarkdown(catalog); + const named = command.config.component; + rendered = + named === undefined + ? // The compact catalog, unchanged: routine discovery output and every + // default Plan prompt read it, and long documentation would make both + // unnecessarily large. + command.config.json + ? renderSyntaxJson(catalog) + : renderSyntaxMarkdown(catalog) + : // The same selection, index and renderer `` uses, so + // the command and the component cannot describe one component two + // ways. JSON stays the compact projection; it is the catalog's shape, + // and documentation is prose rather than a catalog member. + yield* renderSyntaxDocumentation(catalog, [named]); } catch (error) { console.error(describeError(error)); yield* exit(1); diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index a601696a..cec03f9c 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -21,9 +21,12 @@ import type { Operation } from "effection"; import { AGENT_REGISTRATIONS, agentIdentityComponents, + documentationIndexFor, inspectSyntax, registerComponents, + renderSelectedDocumentation, renderSyntaxMarkdown, + selectDocumented, } from "@executablemd/core"; import type { SyntaxCatalog } from "@executablemd/core"; import { TESTING_REGISTRATIONS } from "@executablemd/testing"; @@ -98,3 +101,22 @@ export function* useRunProfileRegistry(): Operation { export function renderSyntaxJson(catalog: SyntaxCatalog): string { return `${JSON.stringify(catalog, null, 2)}\n`; } + +/** + * The selected components' metadata and long-form documentation. + * + * `xmd syntax Elicit` and `` are the same lookup: + * one selection, one index, one renderer. An operator reading a terminal and an + * agent reading a document are answering the same question, and two renderings + * that agreed only by hand would be one release away from disagreeing. + * + * Nothing here narrows execution, so every entry a catalog holds is available + * and each says so. + */ +export function* renderSyntaxDocumentation( + catalog: SyntaxCatalog, + names: readonly string[], +): Operation { + const index = yield* documentationIndexFor(catalog); + return renderSelectedDocumentation(selectDocumented(catalog, catalog, names, index)); +} diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 950478c9..863398dd 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -192,14 +192,16 @@ describe("Tier SX — the run profile the command describes", () => { expect(entry.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); expect(entry.sourceKind).toBe("protected"); expect(entry.forms).toEqual(["self-closing"]); - expect(entry.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); + // One optional prop, closed: `names` selects documentation. + expect(Object.keys((entry.props.properties ?? {}) as object)).toEqual(["names"]); + expect(entry.props.additionalProperties).toBe(false); expect(entry.captures).toEqual([]); expect(entry.returnMode).toBe("text"); expect(entry.description).toBe( - "Output available components and control flow constructs. `` renders the " + - "current catalog.", + "Inspect components and control-flow constructs. `` renders the current " + + 'catalog; `` renders selected documentation.', ); - expect(entry.as).toBe("Optional. Captures the rendered catalog instead of emitting it."); + expect(entry.as).toBe("Optional. Captures the rendered text instead of emitting it."); }); it("ORC1: names all thirteen repository-composition components, with contracts", function* () { @@ -468,6 +470,30 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("SX16: `xmd syntax Elicit` renders the same text the named component does", function* () { + const named = yield* runCli(["syntax", "Elicit", "--include", "."], { cwd: "." }).expect(); + + // Metadata, then documentation, then availability — the detailed renderer, + // not the compact catalog. + expect(named.stdout).toContain("### ``"); + expect(named.stdout).toContain("Asks a person a structured question"); + expect(named.stdout).toContain("**Available in this evaluation:** yes"); + // Only the one asked for: the compact catalog's other entries are absent. + expect(named.stdout).not.toContain("### ``"); + + // The compact form is untouched by the addition. + const compact = yield* runCli(["syntax", "--include", "."], { cwd: "." }).expect(); + expect(compact.stdout).toContain("## Built-in components"); + expect(compact.stdout).not.toContain("Asks a person a structured question"); + + // An unknown name refuses whole rather than printing a partial answer. + const unknown = yield* runCli(["syntax", "Nonexistent", "--include", "."], { + cwd: ".", + }).join(); + expect(unknown.code).not.toBe(0); + expect(unknown.stdout).toBe(""); + }); + it("SX10: writes markdown by default and version-2 JSON with --json", function* () { yield* useWorkspace(WORKSPACE, function* (cwd) { const markdown = yield* runCli(["syntax", "--include", "first"], { cwd }).expect(); diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 699879b3..6be49370 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -217,7 +217,19 @@ export { ComponentIncludeError } from "./src/components/candidates.ts"; * The catalog as Markdown, so `xmd syntax` and canonical `` print the * same bytes for the same site rather than two renderings that agree by hand. */ -export { renderSyntaxMarkdown } from "./src/syntax-markdown.ts"; +export { renderSelectedDocumentation, renderSyntaxMarkdown } from "./src/syntax-markdown.ts"; +export type { SelectedEntry } from "./src/syntax-markdown.ts"; +/** + * The documentation index and the selection that reads it (#678). + * + * Exported because `xmd syntax Elicit` and `` must be the same + * lookup rather than two that agree by hand: the command reaches the index and + * the selection core's own component reaches. + */ +export { documentationIndexFor } from "./src/component-documentation.ts"; +export { select as selectDocumented } from "./src/syntax-observation.ts"; +export { NO_DOCUMENTATION, UnknownComponentError } from "./src/documentation-index.ts"; +export type { DocumentationIndex } from "./src/documentation-index.ts"; export { PROTECTED_COMPONENT_NAMES, ProtectedComponentError } from "./src/components/protected.ts"; export { SYNTAX_COMPONENT } from "./src/components/Syntax.ts"; // Document validation — one supplied document read as authored program diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts new file mode 100644 index 00000000..edb1d0c4 --- /dev/null +++ b/packages/core/src/component-documentation.ts @@ -0,0 +1,73 @@ +/** + * The documentation canonical core ships for the components it owns. + * + * The bytes live in `src/components/components.md`, beside the registration + * boundary they document, and are located from this module's own URL. Never + * from the working directory and never through `--include`: a documentation set + * that moved with the caller's directory would describe a different product + * depending on where somebody stood, and a repository file could answer for it. + * + * Each build keeps the asset beside its module — `deno compile --include` + * embeds it at the same relative path, the npm build copies it into the emitted + * tree, and JSR publishes the source file — so the one lookup below is correct + * in all four, and a build that forgets the asset fails loudly on first use + * rather than quietly serving a product with no documentation. + */ + +import { readFile } from "node:fs/promises"; +import { until } from "effection"; +import type { Operation } from "effection"; + +import { buildDocumentationIndex } from "./documentation-index.ts"; +import type { DocumentationIndex, DocumentationSource } from "./documentation-index.ts"; +import { CORE_ORIGIN } from "./components/registry.ts"; +import type { SyntaxCatalog } from "./inspect.ts"; +import { owningPackage } from "./documentation-index.ts"; + +/** Where core's own documentation lives, as a URL beside this module. */ +export function componentDocumentationUrl(): URL { + return new URL("./components/components.md", import.meta.url); +} + +/** Core's documentation source, read from the package rather than the caller. */ +export function* readCoreDocumentation(): Operation { + const url = componentDocumentationUrl(); + try { + return { + owner: CORE_ORIGIN, + asset: "packages/core/src/components/components.md", + text: yield* until(readFile(url, "utf8")), + }; + } catch (error) { + throw new Error( + `the packaged component documentation is missing from this build (looked in ${url.href})`, + { cause: error }, + ); + } +} + +/** + * The index for one catalog, validated against what that catalog actually holds. + * + * The catalog supplies the known names, so a heading naming something this build + * does not supply is caught here rather than becoming an entry nothing can ever + * select. That is also what keeps the index and the catalog from disagreeing + * about which components exist. + */ +export function* documentationIndexFor(catalog: SyntaxCatalog): Operation { + const sources = [yield* readCoreDocumentation()]; + return buildDocumentationIndex(sources, (owner) => namesOwnedBy(catalog, owner)); +} + +/** Every component in this catalog that the named package supplies. */ +function namesOwnedBy(catalog: SyntaxCatalog, owner: string): ReadonlySet { + const names = new Set(); + for (const category of catalog.categories) { + for (const entry of category.entries) { + if (owningPackage(entry.origin) === owner) { + names.add(entry.name); + } + } + } + return names; +} diff --git a/packages/core/src/components/Syntax.ts b/packages/core/src/components/Syntax.ts index 8125aab3..a8875704 100644 --- a/packages/core/src/components/Syntax.ts +++ b/packages/core/src/components/Syntax.ts @@ -65,13 +65,30 @@ const SYNTAX_CATALOG = "syntax_catalog"; */ export const props: PropsSchema = { type: "object", - properties: {}, + properties: { + names: { + type: "array", + items: { type: "string" }, + minItems: 1, + uniqueItems: true, + description: + "Optional. Render these components' catalog metadata and long-form documentation " + + "instead of the compact catalog. Entries render once each, in catalog order.", + }, + }, additionalProperties: false, }; const PAIRED_REFUSAL = " renders the current catalog and reads no content, so it is written self-closing."; +const NAMES_REFUSAL = + " takes a non-empty list of component names, each a string."; + +const DUPLICATE_REFUSAL = + " takes each component name once: an entry renders once however " + + "many times it is asked for."; + const UNISSUED_REFUSAL = " is invoked by canonical core; this is not an invocation the engine issued."; @@ -98,9 +115,9 @@ export const SYNTAX_PROTECTED: ProtectedComponent = { forms: ["self-closing"], ...documented({ description: - "Output available components and control flow constructs. `` renders the " + - "current catalog.", - as: "Optional. Captures the rendered catalog instead of emitting it.", + "Inspect components and control-flow constructs. `` renders the current " + + 'catalog; `` renders selected documentation.', + as: "Optional. Captures the rendered text instead of emitting it.", context: null, }), build: (claim: IdentityClaimant) => syntax(claim), @@ -108,7 +125,7 @@ export const SYNTAX_PROTECTED: ProtectedComponent = { function syntax(claim: IdentityClaimant): ProtectedBody { return function* observeCatalog( - _props: Record, + props: Record, invocation: ComponentInvocation, observation: CatalogObservation | undefined, ): Operation { @@ -123,15 +140,53 @@ function syntax(claim: IdentityClaimant): ProtectedBody { if (form === "paired") { throw new ComponentInvocationError(PAIRED_REFUSAL); } + // Read before anything is claimed or observed, so a list this component + // cannot answer for refuses with no durable record and no partial text. + // The schema has already rejected an empty list, a duplicate and a + // non-string member; what is left is whether the value is the array shape + // this reads, because a protected body is handed props rather than trusting + // that somebody validated them. + const names = requestedNames(props.names); const id = yield* claim(invocation); if (observation === undefined) { throw new Error(NO_OBSERVATION_REFUSAL); } const expansion = yield* getExpansion(); - return yield* persistCatalog(id, expansion.position, () => observation.observe()); + return yield* persistCatalog(id, expansion.position, () => + names === undefined ? observation.observe() : observation.document(names), + ); }; } +/** + * The names this occurrence asked to document, or nothing for the bare form. + * + * The declared schema is the first gate and rejects an empty list, a duplicate + * and a non-string member before the body is entered. This is the second, and it + * exists because a body is handed a props object rather than a promise that one + * was checked: a value that is not the shape this reads is refused here rather + * than becoming an empty selection that renders the whole catalog. + */ +function requestedNames(value: Json | undefined): readonly string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || value.length === 0) { + throw new ComponentInvocationError(NAMES_REFUSAL); + } + const names: string[] = []; + for (const member of value) { + if (typeof member !== "string" || member.length === 0) { + throw new ComponentInvocationError(NAMES_REFUSAL); + } + if (names.includes(member)) { + throw new ComponentInvocationError(DUPLICATE_REFUSAL); + } + names.push(member); + } + return names; +} + function* persistCatalog( id: string, position: Readonly | undefined, diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md new file mode 100644 index 00000000..b815539a --- /dev/null +++ b/packages/core/src/components/components.md @@ -0,0 +1,112 @@ +Long-form documentation for the components canonical core owns. + +Each level-two heading below is the exact name of one component. The compact +catalog — `xmd syntax`, or a bare `` — lists every component with its +forms, props and one-line description. This file holds the part that does not +belong in a list: when to reach for a component, what it does at run time, and +what it will refuse. + +A component with no section here is still ordinary and still usable. Selecting +it by name reports that no long-form documentation is available for it yet. + +## Syntax + +Renders the catalog of components and control-flow constructs available where +the element is written. + +```mdx + +``` + +The bare form renders the compact catalog: every name a document may write at +that site, with its forms, props and description. It is the same text +`xmd syntax` prints, built by the same code, so an operator reading a terminal +and an agent reading a document are never told different things about one +profile. + +```mdx + +``` + +The named form renders the selected components' catalog metadata followed by the +long-form documentation on this page. Use it when something needs to know how to +use a few specific components rather than what exists — a prompt that has to +explain `` does not need the other seventy entries. Entries render once +each, in catalog order, whatever order they were asked for in. + +`as` captures the rendered text instead of emitting it, in either form: + +```mdx + +``` + +### What the catalog describes + +The site, not the product. It reflects the host profile the execution is running +under, its working directory and includes, the workflow bundle or declared +components it is closed over, and any narrowing a trusted evaluation boundary +applied. Two sites in one document can therefore answer differently, and that is +the point: the answer is what *this* element may write. + +Inside an evaluation that narrows what may execute, the bare form reports the +narrowed vocabulary, while the named form still explains components from the +enclosing authoring catalog and states for each whether it is available in the +current evaluation. Reference material and execution authority are different +questions, and conflating them would either hide documentation an author needs +or imply an authority they do not have. + +### What it refuses + +An empty `names` list, a duplicate name, a member that is not a string, and a +name no catalog entry matches are each refused before anything is observed, so a +refusal produces no partial catalog and no retained result. A paired spelling +and any prop other than `names` and `as` are refused the same way. + +### What it does not do + +Seeing a component in a catalog is not permission to run it. The catalog and +this documentation are text; what a name means is still resolution's decision, +and what may run is still the execution's. + +## Elicit + +Asks a person a structured question and returns their answer. + +```mdx + +Which release should ship first? + +``` + +The content is the request shown to the person. `schema` is a JSON Schema the +answer is validated against, so what comes back is the shape the document said +it needed rather than free text a later step has to interpret. The answer binds +through `as`. + +The question is asked once and retained. A run that resumes after the answer was +given restores it rather than asking again, which is what makes a document with +an elicitation in it safe to interrupt. + +How the question reaches a person is the host's: a terminal prompts, and another +host may route it somewhere else entirely. The document states what it needs to +know, not how to ask. + +## File + +Reads or writes a file, relative to the working directory. + +```mdx + + + +The content to write. + +``` + +The self-closing form reads the file and renders its content. The paired form +writes its content to the path. Both are ordinary durable effects: a write that +already happened is not repeated on a continuation, and a read restores what it +read rather than re-reading a file that has since changed. + +A read of a path that does not exist fails. The write form creates the file and +the directories above it as needed. diff --git a/packages/core/src/documentation-index.ts b/packages/core/src/documentation-index.ts new file mode 100644 index 00000000..2499fcc0 --- /dev/null +++ b/packages/core/src/documentation-index.ts @@ -0,0 +1,246 @@ +/** + * The long-form documentation a package ships beside the components it owns. + * + * `xmd syntax` and bare `` answer *what may I write here* — a compact + * catalog of names, forms and one-line descriptions. Neither answers *how do I + * use this one*, and an agent handed the compact catalog has to guess. So a + * package that registers components also ships their documentation, and this + * module turns those files into one validated index that ``, + * `xmd syntax Elicit` and the release reference all read (#678). + * + * ## Beside the components, not beside the website + * + * A registration bundle keeps `components.md` beside its own registration + * boundary, and the bytes are located from the owning module's URL — never from + * the working directory and never through `--include`. A documentation set that + * moved with the caller's directory would describe a different product depending + * on where somebody stood, and a repository file could answer for it. + * + * ## The shape, and why it validates + * + * A level-two heading is an exact public component name. Everything before the + * first one documents the bundle. Level-three and deeper headings belong to the + * component whose section they are in, so a component's own documentation can + * have structure without ending its section. + * + * Three things refuse the whole index rather than producing a partial one: + * + * - a **duplicate** heading, in one file or across two, because then a component + * has two documentations and nothing says which is current; + * - an **unknown** heading, because it is documentation for something this + * boundary does not register — a rename that updated one side, usually; + * - a heading that is **not a component name at all**, which is a file that has + * drifted from this format into ordinary prose. + * + * A component with *no* section is not a failure. It renders the sentence + * `` states for one, and stays usable while its documentation is still + * being written. + * + * ## The join is name *and* origin + * + * Documentation is attached by both together. A repository `Elicit.md` is a + * different component from the built-in `Elicit` however it is spelled, and + * handing it the built-in's prose would describe behaviour the author's own file + * does not have. + */ + +import type { ComponentOrigin } from "./types.ts"; + +/** One package's documentation for the components it registers. */ +export interface DocumentationSource { + /** + * The package this file documents, as its components report it — + * `@executablemd/core`, for the components canonical core owns. + * + * Half of the join, and a package rather than one origin *value* because a + * single registration boundary supplies components of more than one origin + * kind: canonical core owns `Syntax` in the protected tier and registers + * `Elicit` and `File` beside it, and all three are documented in one file. + * + * What this deliberately cannot match is an origin that names no package: a + * repository path, a workflow blob, a host's declared Markdown. A repository + * `Elicit.md` is a different component that happens to share a name, and + * handing it the built-in's prose would describe behaviour the author's own + * file does not have. + */ + readonly owner: string; + /** Where the bytes came from, so a refusal names a file somebody can open. */ + readonly asset: string; + readonly text: string; +} + +/** + * The package an origin names, or nothing when it names none. + * + * Only a registration and a protected component come from a package. A + * repository file, a bundled blob and declared Markdown are all *this run's*, + * however they are spelled, so no package-owned documentation is theirs. + */ +export function owningPackage(origin: ComponentOrigin): string | undefined { + if (origin.kind === "registered" || origin.kind === "protected") { + return origin.origin; + } + return undefined; +} + +/** A documentation set this version will not build an index from. */ +export class DocumentationIndexError extends Error { + override name = "DocumentationIndexError"; +} + +/** + * A name selected for documentation that this site has no component for. + * + * Its own error because it is the author's mistake rather than the build's: a + * misspelling, or a component that is not on this profile. It refuses the whole + * lookup, so nothing is rendered and nothing is retained. + */ +export class UnknownComponentError extends Error { + override name = "UnknownComponentError"; +} + +/** The long-form documentation one component has, if it has any. */ +export interface DocumentationIndex { + /** The documentation for exactly this component, or nothing when it has none. */ + documentationFor(name: string, origin: ComponentOrigin): string | undefined; + /** What the bundle at this origin says about itself, if anything. */ + bundleDocumentation(origin: ComponentOrigin): string | undefined; +} + +/** What a component with no authored documentation renders instead of prose. */ +export const NO_DOCUMENTATION = "No long-form documentation is available for this component."; + +/** A level-two heading, captured without its marker. */ +const HEADING = /^##\s+(.+?)\s*$/; +/** Any ATX heading, so a deeper one can be told from a section boundary. */ +const ANY_HEADING = /^(#{1,6})\s+/; +/** A fence, so a heading inside a code block is code rather than a section. */ +const FENCE = /^\s*(```+|~~~+)/; +/** What a public component name may be: the same shape an element may write. */ +const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*$/; + +/** One source, parsed into the bundle's own prose and a section per component. */ +interface ParsedSource { + readonly bundle: string; + readonly sections: ReadonlyMap; +} + +/** + * Parse one `components.md`. + * + * Fences are tracked because a documentation file is mostly examples, and an + * example that writes `## Heading` inside a fenced block is showing Markdown + * rather than starting a section. Reading it as a section would silently move + * every following component's prose into the wrong entry. + */ +export function parseDocumentationSource(source: DocumentationSource): ParsedSource { + const lines = source.text.split(/\r?\n/); + const sections = new Map(); + const bundle: string[] = []; + let current: string[] = bundle; + let fence: string | undefined; + + for (const line of lines) { + const fenced = FENCE.exec(line); + if (fenced !== undefined && fenced !== null) { + const marker = fenced[1] ?? ""; + if (fence === undefined) { + fence = marker[0]; + } else if (marker.startsWith(fence)) { + fence = undefined; + } + current.push(line); + continue; + } + if (fence !== undefined) { + current.push(line); + continue; + } + const heading = HEADING.exec(line); + // A level-two heading opens a section; `###` and deeper stay in the one + // they are written in, which is what lets a component's documentation have + // headings of its own. + const depth = ANY_HEADING.exec(line)?.[1]?.length; + if (heading === null || depth !== 2) { + current.push(line); + continue; + } + const name = heading[1] ?? ""; + if (!COMPONENT_NAME.test(name)) { + throw new DocumentationIndexError( + `${source.asset} has the level-two heading "${name}", which is not a component name. ` + + "Every level-two heading in a component documentation file names one component.", + ); + } + if (sections.has(name)) { + throw new DocumentationIndexError( + `${source.asset} documents ${name} twice, so nothing says which section is current.`, + ); + } + current = []; + sections.set(name, current); + } + + return { + bundle: joined(bundle), + sections: new Map([...sections].map(([name, body]) => [name, joined(body)])), + }; +} + +/** A section's lines as one string, with the blank edges trimmed off. */ +function joined(lines: readonly string[]): string { + return lines.join("\n").trim(); +} + +/** + * Build the index every documentation reader shares. + * + * `known` is what each origin actually registers, so a heading naming something + * else is caught here rather than becoming an entry nothing can ever select. It + * is the catalog's own answer, which is what keeps the index and the catalog + * from disagreeing about which components exist. + */ +export function buildDocumentationIndex( + sources: readonly DocumentationSource[], + known: (owner: string) => ReadonlySet, +): DocumentationIndex { + const documentation = new Map>(); + const bundles = new Map(); + + for (const source of sources) { + const parsed = parseDocumentationSource(source); + const registered = known(source.owner); + const held = documentation.get(source.owner) ?? new Map(); + for (const [name, body] of parsed.sections) { + if (!registered.has(name)) { + throw new DocumentationIndexError( + `${source.asset} documents ${name}, which ${source.owner} does not supply. ` + + "Documentation for a component nothing there declares can never be selected.", + ); + } + // Across sources as well as within one: two files documenting one + // component of one package is the same ambiguity as one file doing it. + if (held.has(name)) { + throw new DocumentationIndexError( + `${name} is documented twice for ${source.owner}, so nothing says which is current.`, + ); + } + held.set(name, body); + } + documentation.set(source.owner, held); + if (parsed.bundle.length > 0) { + bundles.set(source.owner, parsed.bundle); + } + } + + return { + documentationFor(name: string, origin: ComponentOrigin): string | undefined { + const owner = owningPackage(origin); + return owner === undefined ? undefined : documentation.get(owner)?.get(name); + }, + bundleDocumentation(origin: ComponentOrigin): string | undefined { + const owner = owningPackage(origin); + return owner === undefined ? undefined : bundles.get(owner); + }, + }; +} diff --git a/packages/core/src/syntax-markdown.ts b/packages/core/src/syntax-markdown.ts index 13af7b9a..3e409e98 100644 --- a/packages/core/src/syntax-markdown.ts +++ b/packages/core/src/syntax-markdown.ts @@ -22,6 +22,7 @@ import type { StructuralSyntaxEntry, SyntaxCatalog, } from "./inspect.ts"; +import { NO_DOCUMENTATION } from "./documentation-index.ts"; import type { ComponentOrigin, Json, PropsSchema } from "./types.ts"; /** The three category kinds, taken from the catalog rather than restated. */ @@ -54,6 +55,41 @@ export function renderSyntaxMarkdown(catalog: SyntaxCatalog): string { return `${sections.join("\n\n")}\n`; } +/** One catalog entry, as the named form selects it. */ +export interface SelectedEntry { + readonly entry: + | StructuralSyntaxEntry + | CompleteComponentSyntaxEntry + | OriginOnlyComponentSyntaxEntry; + /** The long-form documentation this entry has, if it has any. */ + readonly documentation: string | undefined; + /** + * Whether the current evaluation can actually run this component. + * + * Stated rather than implied, because the named form reads from the enclosing + * authoring catalog: inside a narrowed evaluation it can explain a component + * the evaluation may not execute, and a reader shown documentation with no + * word about availability would reasonably assume they had both. + */ + readonly available: boolean; +} + +/** + * The selected entries, each with its metadata and its long-form documentation. + * + * What `` and `xmd syntax Elicit` both render — one renderer, + * so the component and the command cannot describe one component two ways. + */ +export function renderSelectedDocumentation(selected: readonly SelectedEntry[]): string { + const sections = selected.map((one) => { + const blocks = renderEntry(one.entry); + blocks.push(`**Available in this evaluation:** ${one.available ? "yes" : "no"}`); + blocks.push(one.documentation ?? NO_DOCUMENTATION); + return blocks.join("\n\n"); + }); + return `${sections.join("\n\n")}\n`; +} + function renderEntry( entry: StructuralSyntaxEntry | CompleteComponentSyntaxEntry | OriginOnlyComponentSyntaxEntry, ): string[] { diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 9536e016..207c9b17 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -32,7 +32,11 @@ import type { Operation } from "effection"; import { inspectSyntax } from "./inspect.ts"; import type { SyntaxCatalog } from "./inspect.ts"; -import { renderSyntaxMarkdown } from "./syntax-markdown.ts"; +import { renderSelectedDocumentation, renderSyntaxMarkdown } from "./syntax-markdown.ts"; +import type { SelectedEntry } from "./syntax-markdown.ts"; +import { documentationIndexFor } from "./component-documentation.ts"; +import type { DocumentationIndex } from "./documentation-index.ts"; +import { UnknownComponentError } from "./documentation-index.ts"; import type { WorkflowImportAuthority } from "./components/bundle.ts"; import type { DeclaredMarkdownComponent } from "./components/declared-markdown.ts"; import type { IdentityComponent } from "./invocation-identity.ts"; @@ -49,6 +53,22 @@ import type { ComponentRegistry } from "./types.ts"; export interface CatalogObservation { /** The catalog this site describes, rendered as Markdown. */ observe(): Operation; + /** + * The selected components' metadata and long-form documentation. + * + * Two inputs, not one, and this is the reason the observation is an object + * rather than a string. *What may I write here* and *what may I read about* + * are different questions, and a narrowing evaluation boundary answers them + * differently on purpose: the vocabulary it admits is smaller than the + * vocabulary an author is entitled to understand. + * + * So selection reads the **enclosing authoring catalog**, which is why a + * nested Plan can be told how `` works even where it may not run one, + * and each rendered entry states whether it is available in the current + * evaluation. Collapsing the two would either hide reference material an + * author needs or imply an authority they do not have. + */ + document(names: readonly string[]): Operation; } /** @@ -88,15 +108,72 @@ export function rootCatalogObservation( inputs: CapturedCatalogInputs, contribution: CatalogContribution | undefined, ): CatalogObservation { + function* current(): Operation { + return contribution === undefined ? yield* derived(inputs) : yield* contribution(); + } return { *observe(): Operation { - return renderSyntaxMarkdown( - contribution === undefined ? yield* derived(inputs) : yield* contribution(), - ); + return renderSyntaxMarkdown(yield* current()); + }, + *document(names: readonly string[]): Operation { + // At the root the two inputs are one catalog: nothing has narrowed what + // may execute, so what an author may read about and what they may run are + // the same set, and every selected entry is available. + const catalog = yield* current(); + const index = yield* documentationIndexFor(catalog); + return renderSelectedDocumentation(select(catalog, catalog, names, index)); }, }; } +/** + * The selected entries, in catalog order, with their documentation and + * availability. + * + * `reference` is the catalog selection reads; `executable` is what the current + * evaluation may actually run. At a root they are the same object. Under a + * narrowing boundary they are not, and the difference is what each entry's + * availability reports. + */ +export function select( + reference: SyntaxCatalog, + executable: SyntaxCatalog, + names: readonly string[], + index: DocumentationIndex, +): SelectedEntry[] { + const requested = new Set(names); + const runnable = new Set( + executable.categories.flatMap((category) => category.entries.map((entry) => entry.name)), + ); + const selected: SelectedEntry[] = []; + // Walked in catalog order rather than request order, so two documents asking + // for the same components in different orders render the same text — which is + // what makes one occurrence's retained result comparable with another's. + for (const category of reference.categories) { + for (const entry of category.entries) { + if (!requested.has(entry.name)) { + continue; + } + requested.delete(entry.name); + selected.push({ + entry, + documentation: index.documentationFor(entry.name, entry.origin), + available: runnable.has(entry.name), + }); + } + } + // Whatever is left named nothing this site has. Refused whole rather than + // rendered partially: a reader handed three of the four components they asked + // about has no way to tell which request went unanswered. + if (requested.size > 0) { + throw new UnknownComponentError( + ` was asked to document ${[...requested].sort().join(", ")}, which ` + + `${requested.size === 1 ? "is not a component" : "are not components"} available here.`, + ); + } + return selected; +} + function* derived(inputs: CapturedCatalogInputs): Operation { return yield* inspectSyntax({ includes: inputs.includes, @@ -116,12 +193,32 @@ function* derived(inputs: CapturedCatalogInputs): Operation { * adds nothing: the catalog handed here is the admission's, so an entry that is * not in the admission cannot be in the observation. */ -export function fixedCatalogObservation(catalog: SyntaxCatalog): CatalogObservation { +export function fixedCatalogObservation( + catalog: SyntaxCatalog, + /** + * The authoring catalog this boundary is nested in. + * + * Where the two inputs come apart. `catalog` is what may *execute* here, and + * this is what may be *read about* — the vocabulary of the site the evaluation + * was written at. Omitted, the two are the same, which is the ordinary case + * for a boundary that narrows nothing. + * + * A narrowing boundary passes both, and named selection then explains a + * component this evaluation cannot run while saying so on the entry. Dropping + * the enclosing catalog instead would leave a nested author unable to look up + * the very components they are being asked to write about. + */ + reference: SyntaxCatalog = catalog, +): CatalogObservation { const rendered = renderSyntaxMarkdown(catalog); return { // deno-lint-ignore require-yield *observe(): Operation { return rendered; }, + *document(names: readonly string[]): Operation { + const index = yield* documentationIndexFor(reference); + return renderSelectedDocumentation(select(reference, catalog, names, index)); + }, }; } diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 3e418058..c8cf73b4 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -58,8 +58,8 @@ const ROOT_PATH = "documents/root.md"; /** The approved description, spelled here so a change to it fails a test. */ const DESCRIPTION = - "Output available components and control flow constructs. `` renders the " + - "current catalog."; + "Inspect components and control-flow constructs. `` renders the current " + + 'catalog; `` renders selected documentation.'; /** A catalog with one built-in entry per name, for a case that needs a marker. */ function catalogOf(...names: readonly string[]): SyntaxCatalog { @@ -133,6 +133,19 @@ function observations(events: readonly DurableEvent[]): DurableEvent[] { ); } +/** + * Only the observations that succeeded. + * + * A refusal still records the attempt and its failure, which is how a journal + * says what happened. What must not exist is a *successful* record: that is the + * thing a continuation would restore and hand back as a catalog. + */ +function retained(events: readonly DurableEvent[]): DurableEvent[] { + return observations(events).filter( + (event) => event.type === "yield" && event.result.status === "ok", + ); +} + /** A continuation stream: everything one run recorded but its terminals. */ function* continuing(stream: InMemoryStream): Operation { const partial = new InMemoryStream(); @@ -252,6 +265,68 @@ describe("Tier SYN — what one occurrence answers", () => { }); }); +describe("Tier SYN — the named form", () => { + it("SYN29: renders the selected entries' metadata and documentation, and captures it", function* () { + const named = String(yield* run('\n')); + + // Both selected, each once, with metadata and long-form documentation. + expect(named).toContain("### ``"); + expect(named).toContain("### ``"); + expect(named).toContain("Asks a person a structured question"); + expect(named).toContain("Reads or writes a file"); + // Catalog order, not request order: `Elicit` precedes `File` alphabetically + // and the request asked for them the other way round. + expect(named.indexOf("### ``")).toBeLessThan(named.indexOf("### ``")); + // Nothing but the selection: the rest of the catalog is not here. + expect(named).not.toContain("### ``"); + + // `as` binds the same text and emits nothing of it. + const captured = String( + yield* run(['', "{reference}", ""].join("\n")), + ); + const bare = String(yield* run('\n')); + expect(captured.trim()).toBe(bare.trim()); + }); + + it("SYN30: states availability, and says so when documentation is absent", function* () { + const named = String(yield* run('\n')); + // At a root nothing has narrowed execution, so a selected entry is + // available by construction. + expect(named).toContain("**Available in this evaluation:** yes"); + + // A component core supplies but has not documented yet renders its + // metadata and says the documentation is missing, rather than refusing. + // A structural construct comes from no package at all, so no package-owned + // documentation can ever be its — the join has nothing to match on. + const undocumented = String(yield* run('\n')); + expect(undocumented).toContain("### ``"); + expect(undocumented).toContain("No long-form documentation is available for this component."); + }); + + it("SYN31: refuses an unusable list before observing anything", function* () { + const stream = new InMemoryStream(); + const unknown = yield* refusal(run('\n', [], stream)); + expect(unknown).toContain("Nonexistent"); + // No successful record: the attempt and its failure are journaled, as any + // effect's are, but there is nothing for a continuation to restore and hand + // back as a catalog. + expect(retained(yield* stream.readAll())).toHaveLength(0); + + for (const written of [ + "", + '', + "", + '', + '', + ]) { + const each = new InMemoryStream(); + const message = yield* refusal(run(`${written}\n`, [], each)); + expect([written, message.length > 0]).toEqual([written, true]); + expect([written, retained(yield* each.readAll()).length]).toEqual([written, 0]); + } + }); +}); + describe("Tier SYN — the name canonical core owns", () => { it("SYN5: a repository Syntax.md, Syntax.ts and directory candidate never win", function* () { yield* useWorkingDirectory(function* (dir) { @@ -829,7 +904,23 @@ describe("Tier SYN — observation is never authority", () => { expect(entry?.description).toBe(DESCRIPTION); expect(entry?.forms).toEqual(["self-closing"]); expect(entry?.returnMode).toBe("text"); - expect(entry?.props).toEqual({ type: "object", properties: {}, additionalProperties: false }); + // One optional prop, closed: `names` selects documentation, and anything + // else is refused before an observation. + expect(entry?.props).toEqual({ + type: "object", + properties: { + names: { + type: "array", + items: { type: "string" }, + minItems: 1, + uniqueItems: true, + description: + "Optional. Render these components' catalog metadata and long-form documentation " + + "instead of the compact catalog. Entries render once each, in catalog order.", + }, + }, + additionalProperties: false, + }); expect(entry?.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); // Exactly one entry, in exactly one category. const everywhere = catalog.categories.flatMap((category) => diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index dfa43408..38a1e270 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -50,12 +50,23 @@ import { z } from "npm:zod@^4"; * a document part of the product. */ function* packagedDocuments(pkgDir: URL): Operation { + const shipped: string[] = []; + // Component documentation lives beside the registration boundary it + // documents rather than in `src/documents/`, because that is where the + // components are and moving it would separate the two things that have to + // stay in step. Named by its exact path for the same reason the directory + // above is enumerated rather than swept for: being listed here is what + // declares an asset part of the product. + const documentation = new URL("src/components/components.md", pkgDir); + if (yield* exists(documentation)) { + shipped.push("src/components/components.md"); + } const documents = new URL("src/documents/", pkgDir); if (!(yield* exists(documents))) { - return []; + return shipped; } const names = yield* until(readdir(fromFileUrl(documents), { recursive: true })); - return names.map((name) => `src/documents/${name.split(sep).join("/")}`); + return [...shipped, ...names.map((name) => `src/documents/${name.split(sep).join("/")}`)]; } const ExportsSchema = z.union([z.string(), z.record(z.string(), z.string())]); From c0dc9d02beb0c8c9d98f4f92947a361382be394c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 23:00:17 -0400 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=94=8D=20Validate=20the=20documenta?= =?UTF-8?q?tion=20index=20against=20the=20package,=20not=20the=20catalog?= =?UTF-8?q?=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index checked its headings against whichever catalog was in scope. Those are different sets, and conflating them is a real bug: a narrowing evaluation boundary carries a catalog holding a handful of admitted components, so validating core's own `components.md` against it reported `Elicit` as something `@executablemd/core` does not supply — and the two-input seam refused to build its index at all. A heading has to name a component this *build* ships; which of them a given site can select is what the selection answers. The known set is now core's own registrations plus the protected tier, read from the same declarations execution reads. Adds the evidence the amended contract asks for: - the narrowing seam, proved without an ``: a narrowed observation reports the narrowed vocabulary bare, documents the enclosing catalog by name, and marks availability truthfully in both directions; - named retention — the occurrence retains its final rendered text, a continuation restores it without rereading documentation, a corrupted record refuses; - the index itself: bundle prose and per-component sections, deeper headings kept inside their section, a fenced heading read as the example it is, and refusals for a duplicate section, a heading that is not a component name, a heading naming something the package does not supply, and one component documented twice; - the join, positively and negatively: a registration and a protected component both receive the package's prose, a repository replacement of the same name receives none; - `xmd syntax Elicit` equivalence, with the compact catalog and an unknown name as controls; - the compiled binary documenting a component from a directory that is not the checkout, which is what proves the embedded asset rather than the entry. The new test file runs under Deno, Node and Bun, so it joins all three shards rather than needing an exclusion. Reconciles the remaining stale version-1 claims: the `SyntaxCatalog` paragraph and the `xmd syntax` row in architecture.md, and the Syntax Markdown suite. --- architecture.md | 27 +++- packages/cli/src/syntax.ts | 2 +- .../document-suites/syntax/Syntax.test.md | 2 +- packages/core/src/component-documentation.ts | 47 +++--- packages/core/src/syntax-observation.ts | 4 +- .../core/tests/documentation-index.test.ts | 143 ++++++++++++++++++ packages/core/tests/syntax-component.test.ts | 67 ++++++++ scripts/tests/cli-npm-bin.test.ts | 4 +- scripts/tests/plan-component-compiled.test.ts | 23 ++- specs/executable-mdx-spec.md | 42 ++++- 10 files changed, 327 insertions(+), 34 deletions(-) create mode 100644 packages/core/tests/documentation-index.test.ts diff --git a/architecture.md b/architecture.md index a23b85ec..8207f382 100644 --- a/architecture.md +++ b/architecture.md @@ -3691,6 +3691,29 @@ canonical expansion, so an implementation another loaded copy created — which an ordinary arrangement, because a component can be loaded from disk beside its own copy — has no body here and no answer to give. +**The named form is a second question.** Bare `` answers *what may I +write here*. `` answers *how do I use this one*, and +the two read different inputs on purpose. The observation therefore carries a +pair: what may **execute** at this site, which the bare form reports, and the +**enclosing authoring catalog**, which named selection reads. At a root they are +one catalog. Under a trusted evaluation boundary that narrows execution they are +not, and each rendered entry states whether it is available in the current +evaluation — so a nested author can be told how a component works where they may +not run one, without being left to infer that documentation implies authority. +#713 installs that boundary; this stack supplies and proves the seam. + +The documentation itself is the owning package's. A registration bundle keeps +`components.md` beside its own boundary, located from that module's URL rather +than the working directory or `--include`, and every distribution loads the same +bytes. One validated index serves ``, `xmd syntax Elicit` and +#678's release reference, so three surfaces cannot describe one component three +ways. It joins by name *and* owning package: a repository `Elicit.md` has a +repository origin, which names no package, so the built-in's prose is never +attached to it. A heading naming something the package does not supply, one +appearing twice, and one that is not a component name each refuse the whole +index rather than producing a partial one; a component with no section is +ordinary and renders the sentence saying so. + **It says so in the catalog.** A protected component reports its own origin kind, `protected`, rather than borrowing `registered` with `reserved: true`. The two answer a reader's actual question — *could I supply this name myself?* — @@ -3723,7 +3746,7 @@ observations, and repeated reads of one binding observe nothing again. `xmd syntax` answers what a document may write here, and answering must cost nothing. The boundary that makes that true is one operation with no authority. -**One catalog, two projections.** Core produces a `SyntaxCatalog` — version 1, a +**One catalog, two projections.** Core produces a `SyntaxCatalog` — version 2, a fixed three-category tuple, entries sorted by name — and the Markdown and JSON renderers each take that value. Neither renderer discovers anything, and neither parses the other's output, so the two formats cannot describe different @@ -3955,7 +3978,7 @@ Status is measured against main. | Construct | Does | Status | | --- | --- | --- | -| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same catalog for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | +| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-2 JSON, from one catalog. `xmd syntax Elicit` names one component instead and renders its catalog metadata followed by the long-form documentation the owning package ships, through the same selection, index and renderer `` uses. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same catalog for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | | `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, no props, and a text component: the bare form emits the catalog and the ordinary `as` captures the same text and emits nothing, while a paired spelling or an authored prop refuses before any observation. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the catalog says is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile — and it is carried lexically on canonical core's expansion authority rather than through any context. Each occurrence claims the identity the execution minted, performs one `syntax_catalog` observation, and retains exactly `{ catalog: string }`; a continuation hostile-parses that record and restores the catalog the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled observation completes its teardown and commits nothing. It reports itself under its own catalog origin kind, `protected`, never as a reserved registration, and selection records `{ kind: "protected" }` alone — replay asks this execution for the implementation it built rather than resolving the name again. It carries no authority at all: a component named in a catalog is neither registered, resolved nor authorized by being named | built on this stack; the narrower observation a trusted evaluation boundary installs for its subtree is the seam #713 fills | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft and every failed check's structured findings — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index cec03f9c..69525bdc 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -117,6 +117,6 @@ export function* renderSyntaxDocumentation( catalog: SyntaxCatalog, names: readonly string[], ): Operation { - const index = yield* documentationIndexFor(catalog); + const index = yield* documentationIndexFor(); return renderSelectedDocumentation(selectDocumented(catalog, catalog, names, index)); } diff --git a/packages/cli/tests/document-suites/syntax/Syntax.test.md b/packages/cli/tests/document-suites/syntax/Syntax.test.md index 6d9eccf6..9d7e6542 100644 --- a/packages/cli/tests/document-suites/syntax/Syntax.test.md +++ b/packages/cli/tests/document-suites/syntax/Syntax.test.md @@ -16,7 +16,7 @@ not detect one. ## Running the command Three invocations, once each: the default Markdown format, the same catalog as -version-1 JSON, and one more with two includes written in a deliberate order. +version-2 JSON, and one more with two includes written in a deliberate order. ```bash exec as="markdown" "$XMD_SYNTAX_BIN" syntax --include packages/cli/tests/document-suites/syntax/components diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index edb1d0c4..2be3d892 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -20,9 +20,8 @@ import type { Operation } from "effection"; import { buildDocumentationIndex } from "./documentation-index.ts"; import type { DocumentationIndex, DocumentationSource } from "./documentation-index.ts"; -import { CORE_ORIGIN } from "./components/registry.ts"; -import type { SyntaxCatalog } from "./inspect.ts"; -import { owningPackage } from "./documentation-index.ts"; +import { CORE_ORIGIN, CORE_REGISTRY } from "./components/registry.ts"; +import { PROTECTED_COMPONENT_NAMES } from "./components/protected.ts"; /** Where core's own documentation lives, as a URL beside this module. */ export function componentDocumentationUrl(): URL { @@ -47,27 +46,31 @@ export function* readCoreDocumentation(): Operation { } /** - * The index for one catalog, validated against what that catalog actually holds. + * The index every documentation reader shares. * - * The catalog supplies the known names, so a heading naming something this build - * does not supply is caught here rather than becoming an entry nothing can ever - * select. That is also what keeps the index and the catalog from disagreeing - * about which components exist. + * Validated against what the *package* supplies, not against whichever catalog + * is in scope. Those are different sets and conflating them is a real bug: a + * narrowing evaluation boundary carries a catalog holding a handful of admitted + * components, and validating core's own documentation against that would report + * `Elicit` as a component core does not supply. What a heading has to name is a + * component this build actually ships; which of them a given site can select is + * a separate question the selection answers. */ -export function* documentationIndexFor(catalog: SyntaxCatalog): Operation { +export function* documentationIndexFor(): Operation { const sources = [yield* readCoreDocumentation()]; - return buildDocumentationIndex(sources, (owner) => namesOwnedBy(catalog, owner)); + return buildDocumentationIndex(sources, (owner) => + owner === CORE_ORIGIN ? CORE_COMPONENT_NAMES : new Set(), + ); } -/** Every component in this catalog that the named package supplies. */ -function namesOwnedBy(catalog: SyntaxCatalog, owner: string): ReadonlySet { - const names = new Set(); - for (const category of catalog.categories) { - for (const entry of category.entries) { - if (owningPackage(entry.origin) === owner) { - names.add(entry.name); - } - } - } - return names; -} +/** + * Every component canonical core supplies, by name. + * + * Its registrations and the protected tier together — the two ways core puts a + * component into an execution — read from the same declarations execution reads, + * so this cannot drift from what the package actually ships. + */ +const CORE_COMPONENT_NAMES: ReadonlySet = new Set([ + ...CORE_REGISTRY.keys(), + ...PROTECTED_COMPONENT_NAMES, +]); diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 207c9b17..3c8e150c 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -120,7 +120,7 @@ export function rootCatalogObservation( // may execute, so what an author may read about and what they may run are // the same set, and every selected entry is available. const catalog = yield* current(); - const index = yield* documentationIndexFor(catalog); + const index = yield* documentationIndexFor(); return renderSelectedDocumentation(select(catalog, catalog, names, index)); }, }; @@ -217,7 +217,7 @@ export function fixedCatalogObservation( return rendered; }, *document(names: readonly string[]): Operation { - const index = yield* documentationIndexFor(reference); + const index = yield* documentationIndexFor(); return renderSelectedDocumentation(select(reference, catalog, names, index)); }, }; diff --git a/packages/core/tests/documentation-index.test.ts b/packages/core/tests/documentation-index.test.ts new file mode 100644 index 00000000..d348fe21 --- /dev/null +++ b/packages/core/tests/documentation-index.test.ts @@ -0,0 +1,143 @@ +/** + * Tier SYN — the documentation index (#678). + * + * The index is what ``, `xmd syntax Elicit` and the release + * reference all read, so a set that parses wrongly is wrong in three places at + * once. Everything here is about it refusing rather than producing a partial + * answer: a documentation set that has drifted from the components it documents + * is a build problem, and the moment to say so is the build. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; + +import { + buildDocumentationIndex, + DocumentationIndexError, + owningPackage, + parseDocumentationSource, +} from "../src/documentation-index.ts"; +import type { DocumentationSource } from "../src/documentation-index.ts"; +import type { ComponentOrigin } from "../src/types.ts"; + +const OWNER = "@executablemd/test"; + +/** One documentation file, as a package ships it. */ +function source(text: string): DocumentationSource { + return { owner: OWNER, asset: "packages/test/src/components/components.md", text }; +} + +/** What that package supplies, as the index validates headings against. */ +function supplies(...names: readonly string[]): (owner: string) => ReadonlySet { + return (asked) => (asked === OWNER ? new Set(names) : new Set()); +} + +const REGISTERED: ComponentOrigin = { kind: "registered", origin: OWNER, reserved: false }; +const PROTECTED: ComponentOrigin = { kind: "protected", origin: OWNER }; +const REPOSITORY: ComponentOrigin = { kind: "repository", path: "components/Alpha.md" }; + +describe("Tier SYN — parsing one documentation file", () => { + it("SYN32: reads the bundle's own prose, then a section per component", function* () { + const parsed = parseDocumentationSource( + source( + [ + "What this bundle is for.", + "", + "## Alpha", + "", + "About Alpha.", + "", + "### A detail of Alpha", + "", + "Still Alpha.", + "", + "## Beta", + "", + "About Beta.", + "", + ].join("\n"), + ), + ); + + expect(parsed.bundle).toBe("What this bundle is for."); + // A level-three heading stays in the section it is written in, so a + // component's own documentation can have structure. + expect(parsed.sections.get("Alpha")).toContain("### A detail of Alpha"); + expect(parsed.sections.get("Alpha")).toContain("Still Alpha."); + expect(parsed.sections.get("Beta")).toBe("About Beta."); + expect([...parsed.sections.keys()]).toEqual(["Alpha", "Beta"]); + }); + + it("SYN33: reads a heading inside a fence as the example it is", function* () { + const parsed = parseDocumentationSource( + source( + ["## Alpha", "", "Write a heading like this:", "", "```md", "## Beta", "```", ""].join( + "\n", + ), + ), + ); + + // One section, not two: the fenced `## Beta` is Markdown being shown, and + // reading it as a section would move everything after it into the wrong + // component. + expect([...parsed.sections.keys()]).toEqual(["Alpha"]); + expect(parsed.sections.get("Alpha")).toContain("## Beta"); + }); + + it("SYN34: refuses a duplicate section and a heading that is not a name", function* () { + expect(() => + parseDocumentationSource(source(["## Alpha", "one", "", "## Alpha", "two", ""].join("\n"))), + ).toThrow(DocumentationIndexError); + + expect(() => + parseDocumentationSource(source(["## Getting started", "prose", ""].join("\n"))), + ).toThrow(DocumentationIndexError); + }); +}); + +describe("Tier SYN — building the index", () => { + it("SYN35: refuses a heading naming something the package does not supply", function* () { + expect(() => + buildDocumentationIndex([source("## Gamma\n\nAbout Gamma.\n")], supplies("Alpha")), + ).toThrow(DocumentationIndexError); + }); + + it("SYN36: refuses one component documented in two files", function* () { + const first = source("## Alpha\n\nOne.\n"); + const second = { ...first, asset: "packages/test/src/other/components.md" }; + expect(() => buildDocumentationIndex([first, second], supplies("Alpha"))).toThrow( + DocumentationIndexError, + ); + }); + + it("SYN37: attaches documentation by name and owning package together", function* () { + const index = buildDocumentationIndex( + [source("Bundle prose.\n\n## Alpha\n\nAbout Alpha.\n")], + supplies("Alpha"), + ); + + // The package's own components, however core puts them into an execution. + expect(index.documentationFor("Alpha", REGISTERED)).toBe("About Alpha."); + expect(index.documentationFor("Alpha", PROTECTED)).toBe("About Alpha."); + expect(index.bundleDocumentation(REGISTERED)).toBe("Bundle prose."); + + // A repository component that happens to share the name is a different + // component, and gets none of it: its origin names no package at all. + expect(index.documentationFor("Alpha", REPOSITORY)).toBeUndefined(); + expect(owningPackage(REPOSITORY)).toBeUndefined(); + + // Neither does a component this package does not document. + expect(index.documentationFor("Beta", REGISTERED)).toBeUndefined(); + }); + + it("SYN38: builds from a set that documents only some of what it supplies", function* () { + // A component with no section is legal: it renders the sentence `` + // states for one, and stays usable while its documentation is written. + const index = buildDocumentationIndex( + [source("## Alpha\n\nAbout Alpha.\n")], + supplies("Alpha", "Beta"), + ); + expect(index.documentationFor("Alpha", REGISTERED)).toBe("About Alpha."); + expect(index.documentationFor("Beta", REGISTERED)).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index c8cf73b4..5c356a79 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -303,6 +303,37 @@ describe("Tier SYN — the named form", () => { expect(undocumented).toContain("No long-form documentation is available for this component."); }); + it("SYN39: retains the named text, and a continuation restores it whole", function* () { + const stream = new InMemoryStream(); + const first = String(yield* run('\n', [], stream)); + expect(first).toContain("Asks a person a structured question"); + + // Exactly what was rendered, not the compact catalog: the record is the + // occurrence's final text whichever form produced it. + const records = retained(yield* stream.readAll()); + expect(records).toHaveLength(1); + const record = records[0]; + const value = + record?.type === "yield" && record.result.status === "ok" ? record.result.value : undefined; + expect(Object.keys(value as object)).toEqual(["catalog"]); + // The component's own return, which the document then renders — so the two + // differ by the trailing newline presentation adds, and nothing else. + expect(String((value as { catalog: string }).catalog).trim()).toBe(first.trim()); + + // A continuation hands the same text back. The documentation asset is not + // reread and the catalog is not rebuilt: what an agent was shown is what it + // is shown again. + const resumed = String( + yield* run('\n', [], yield* continuing(stream)), + ); + expect(resumed).toBe(first); + + // And a record this version cannot read refuses rather than inventing one. + const corrupted = yield* tampered(stream, () => ({ catalog: "x", extra: 1 })); + const refused = yield* refusal(run('\n', [], corrupted)); + expect(refused).toContain("not a catalog this version can read"); + }); + it("SYN31: refuses an unusable list before observing anything", function* () { const stream = new InMemoryStream(); const unknown = yield* refusal(run('\n', [], stream)); @@ -964,6 +995,42 @@ describe("Tier SYN — observation is never authority", () => { expect(yield* observation.observe()).not.toContain("### ``"); }); + /** + * The seam #713 installs through, proved without an ``. + * + * A narrowing boundary hands the observation two catalogs: what may execute + * in the subtree, and the enclosing authoring catalog selection reads from. + * Everything below is about them being genuinely two. + */ + it("SYN25c: a narrowed observation documents the enclosing site and marks availability", function* () { + const enclosing = catalogOf("Admitted", "Withheld"); + const narrowed = catalogOf("Admitted"); + const observation = fixedCatalogObservation(narrowed, enclosing); + + // What may execute here is the narrowed catalog, and the bare form reports + // exactly that. + const available = yield* observation.observe(); + expect(available).toContain("### ``"); + expect(available).not.toContain("### ``"); + + // Reference material comes from the enclosing catalog, so a component this + // subtree may not run can still be explained — and the entry says so + // rather than leaving a reader to assume they have both. + const documented = yield* observation.document(["Withheld"]); + expect(documented).toContain("### ``"); + expect(documented).toContain("**Available in this evaluation:** no"); + + // And one that is admitted reports the other answer, so the field is + // discriminating rather than a constant. + const admitted = yield* observation.document(["Admitted"]); + expect(admitted).toContain("**Available in this evaluation:** yes"); + + // A boundary that narrows nothing has one catalog, and everything in it is + // available — the ordinary case. + const open = fixedCatalogObservation(enclosing); + expect(yield* open.document(["Withheld"])).toContain("**Available in this evaluation:** yes"); + }); + it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () { // `execute()` driven directly still carries one, so the case that has none // is an expansion driven outside an execution — which is what a component diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 6a8c46f4..e921d880 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -240,8 +240,8 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( - "Output available components and control flow constructs. `` renders the " + - "current catalog.", + "Inspect components and control-flow constructs. `` renders the current " + + 'catalog; `` renders selected documentation.', ); // The command's public grammar travels with those bytes. `--run` is gone, diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index cbd3a12c..8b639e59 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -111,10 +111,29 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( - "Output available components and control flow constructs. `` renders the " + - "current catalog.", + "Inspect components and control-flow constructs. `` renders the current " + + 'catalog; `` renders selected documentation.', ); + // The documentation asset travels with the binary, not with a checkout. A + // build that forgot `--include` would still list the component and still + // print its metadata, and would silently have no prose to attach — so the + // probe is the documentation itself, asked for from a directory that is not + // the checkout. + const lookup = yield* timebox(TIMEOUT, function* () { + return yield* exec(BINARY, { + arguments: ["syntax", "Elicit", "--include", elsewhere], + cwd: elsewhere, + }).join(); + }); + if (lookup.timeout) { + throw new Error("the compiled binary timed out documenting one component"); + } + expect(lookup.value.code).toBe(0); + expect(lookup.value.stdout).toContain("### ``"); + expect(lookup.value.stdout).toContain("Asks a person a structured question"); + expect(lookup.value.stdout).toContain("**Available in this evaluation:** yes"); + // The command surface those bytes belong to is source-only in this build // too: help describes both explicit compositions and names no option that // would run the approved program. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 4b97e113..672214a5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2697,8 +2697,39 @@ engine-owned `as` captures the same text and emits nothing. ``` -It is **self-closing only** and declares no props. A paired spelling and any -authored prop are refused before a catalog is observed. +It is **self-closing only** and declares one optional prop, `names`: a non-empty +array of unique component-name strings. A paired spelling, any other prop, an +empty list, a duplicate, a non-string member and a name this site has no +component for are each refused before a catalog is observed, so a refusal +produces no partial text and no successful retained result. + +**The named form renders documentation.** `` +renders each selected component's catalog metadata followed by the long-form +documentation its owning package ships, once each, **in catalog order** whatever +order they were asked for in. `as` captures the same text in either form. + +Documentation joins to metadata by component name **and owning package**. Only a +registration and a protected component come from a package; a repository file, a +bundled blob and declared Markdown are this run's, so a repository `Elicit.md` +receives none of the built-in `Elicit`'s prose. A selected entry with no authored +documentation renders its metadata and the sentence *No long-form documentation +is available for this component.* rather than refusing. + +**Reference and availability are separate.** The observation carries two inputs. +Bare `` reports what may **execute** at this site. The named form +selects from the **enclosing authoring catalog** and states, per entry, +`**Available in this evaluation:** yes` or `no`. At a root the two are one +catalog and everything selected is available; a trusted evaluation boundary that +narrows execution keeps the enclosing reference, so a nested author can be told +how a component works where they may not run one — and is told which it is. +Neither input carries definitions, import witnesses, invocation capabilities, +providers, registrations or any other execution authority. + +`xmd syntax Elicit` is the same lookup — one selection, one index, one renderer — +so the command and the component cannot describe one component two ways. The +compact `xmd syntax` and its version-2 `--json` are unchanged: documentation is +prose rather than a catalog member, and putting it in routine output would make +every default Plan prompt unnecessarily large. **Canonical core owns the name.** A repository `Syntax.md`, `Syntax.ts` or directory candidate never wins selection; an ordinary or reserved registration @@ -10847,6 +10878,13 @@ component that observes one at an authored site. | SYN26 | Another loaded copy | A protected implementation built by a second loaded copy answers for nothing in the active execution | | SYN27 | Protected provenance | The catalog reports the component under the `protected` origin kind in both the structured entry and the rendered Markdown, and never as a reserved registration; `inspectComponent` agrees | | SYN28 | Pinned provenance | A workflow-bundle component is reported at its path *and* blob object id, under the `workflow` origin kind, and stays in the user-provided category | +| SYN29 | The named form | Selected entries render their metadata and documentation once each in catalog order, not request order, with nothing else of the catalog; `as` binds identical text | +| SYN30 | Availability and absence | Each entry states whether it is available in the current evaluation; a selected entry with no authored documentation renders its metadata and says so | +| SYN31 | Atomic refusal | An unknown name, an empty list, a duplicate, a non-string member, a non-array value and an undeclared prop each refuse with no successful retained result | +| SYN39 | Named retention | The occurrence retains its final rendered text, a continuation restores it without rereading documentation or rebuilding the catalog, and a corrupted record refuses | +| SYN25c | The narrowing seam | A narrowed observation reports the narrowed vocabulary bare, documents the enclosing catalog by name, and marks each entry's availability truthfully in both directions | +| SYN32–SYN34 | Parsing one file | Bundle prose, a section per level-two heading with deeper headings kept inside it, a fenced heading read as the example it is, and a refusal for a duplicate section or a heading that is not a component name | +| SYN35–SYN38 | Building the index | A heading naming something the package does not supply refuses; one component documented twice refuses; documentation attaches by name and owning package, never to a repository replacement; a partly documented package builds | ### Tier SX — The `xmd syntax` command From 234d43f663923f71bf8d281a183ba0b03c0a5d0b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 4 Sep 2026 23:26:12 -0400 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=A9=B9=20Compare=20component=20iden?= =?UTF-8?q?tities,=20not=20names,=20when=20reporting=20availability=20(#75?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections from review, and one of them was a real hole. **Availability compared spelling.** The named form selects its reference entry from the enclosing authoring catalog and marks it available if the current evaluation can run it — but it asked whether anything *called* that could run. The whole point of the two inputs is that the enclosing catalog may hold a different component under the same name, so a reference entry for the built-in `Elicit` beside an admitted repository `Elicit.md` reported the built-in as available: an author told they may execute the thing they were just shown. Availability now compares the complete identity — name and every member of the origin, so a workflow blob differs from another by `sourceHash` and a declared component by `digest` — with negative controls for each way two components can share a name. **The heading grammar excluded namespaced components.** A private regex accepted one capitalised segment, so `File.Delete`, `Session.Launch` and `PullRequest.Reviews` could not be documented or looked up at all. It now uses the canonical `isComponentName()` rather than a second copy of the rule. **Fences closed on the wrong condition.** An example written in four backticks containing a three-backtick block ended at the inner one, so everything after it was read as documentation and a `## Heading` in the example started a section. A fence now closes only on the same character at least as long as the one that opened it. **Shared core read the filesystem directly.** `node:fs/promises` is replaced by the host filesystem operation the root document's own read goes through — not the document-facing `Files` authority, since this is the engine reading its own package. Resolution stays package-relative. Also adds the multi-source assembly the complete index needs: contributions are supplied by trusted host installation, each naming its file and the components it must account for, rather than the index being hardcoded to core. And a Markdown component's own document is now readable as its long-form documentation, since a repository, bundled or declared component belongs to the run rather than to a package and no `components.md` documents it. Reconciles the last version-1 claim in Tier SX and adds its named-lookup row. --- packages/core/src/component-documentation.ts | 51 ++++++++-- packages/core/src/documentation-index.ts | 60 ++++++++++-- packages/core/src/syntax-observation.ts | 40 +++++++- .../core/tests/documentation-index.test.ts | 38 ++++++++ packages/core/tests/syntax-component.test.ts | 94 ++++++++++++++++++- specs/executable-mdx-spec.md | 3 +- 6 files changed, 264 insertions(+), 22 deletions(-) diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index 2be3d892..31b1ebf7 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -14,8 +14,8 @@ * rather than quietly serving a product with no documentation. */ -import { readFile } from "node:fs/promises"; -import { until } from "effection"; +import { fileURLToPath } from "node:url"; +import { readTextFile } from "@executablemd/runtime"; import type { Operation } from "effection"; import { buildDocumentationIndex } from "./documentation-index.ts"; @@ -35,7 +35,13 @@ export function* readCoreDocumentation(): Operation { return { owner: CORE_ORIGIN, asset: "packages/core/src/components/components.md", - text: yield* until(readFile(url, "utf8")), + // The host filesystem operation the root document's own read goes + // through, not the document-facing `Files` authority: this is the engine + // reading its own package, and what a running document installed must not + // decide what its documentation says. The path is derived from this + // module's URL, so it is package-relative whatever the working directory + // and search path are. + text: yield* readTextFile(fileURLToPath(url)), }; } catch (error) { throw new Error( @@ -56,13 +62,44 @@ export function* readCoreDocumentation(): Operation { * component this build actually ships; which of them a given site can select is * a separate question the selection answers. */ -export function* documentationIndexFor(): Operation { - const sources = [yield* readCoreDocumentation()]; - return buildDocumentationIndex(sources, (owner) => - owner === CORE_ORIGIN ? CORE_COMPONENT_NAMES : new Set(), +export function* documentationIndexFor( + /** + * What the packages installed in this execution supply, beside core's own. + * + * Assembled by the trusted host, with the rest of the installation, before any + * document code exists — the Agent, CLI, testing, web and workflow bundles + * each contribute their own file and the set of components it must cover. Not + * a setter and not a context: a document that could add a source could + * describe components it does not have, and one that could remove a source + * could hide the documentation of a component it does. + */ + contributed: readonly DocumentationContribution[] = [], +): Operation { + const core: DocumentationContribution = { + source: yield* readCoreDocumentation(), + supplies: CORE_COMPONENT_NAMES, + }; + const all = [core, ...contributed]; + const supplied = new Map(all.map((one) => [one.source.owner, one.supplies])); + return buildDocumentationIndex( + all.map((one) => one.source), + (owner) => supplied.get(owner) ?? new Set(), ); } +/** One package's documentation, and the components it must account for. */ +export interface DocumentationContribution { + readonly source: DocumentationSource; + /** + * Every public component this package supplies. + * + * Both halves of the check: a heading outside this set is documentation for + * something the package does not have, and a member of it with no heading is + * a component shipped without documentation. + */ + readonly supplies: ReadonlySet; +} + /** * Every component canonical core supplies, by name. * diff --git a/packages/core/src/documentation-index.ts b/packages/core/src/documentation-index.ts index 2499fcc0..ac23df7f 100644 --- a/packages/core/src/documentation-index.ts +++ b/packages/core/src/documentation-index.ts @@ -44,6 +44,7 @@ * does not have. */ +import { isComponentName } from "./components/registration.ts"; import type { ComponentOrigin } from "./types.ts"; /** One package's documentation for the components it registers. */ @@ -107,6 +108,33 @@ export interface DocumentationIndex { bundleDocumentation(origin: ComponentOrigin): string | undefined; } +/** + * A Markdown component's own documentation, taken from its own document. + * + * A repository component, a bundled one and a host's declared Markdown are all + * *this run's* rather than a package's, so no `components.md` documents them. + * Their long-form documentation, when they have any, is the prose in their own + * file — which is where an author writing one would put it, and the only place + * that stays correct when the file changes. + * + * Read from the source the selection already holds, so this loads nothing: a + * repository component's bytes were read to describe it, and a bundled or + * declared one's were admitted before the run began. + */ +export function markdownDocumentation(source: string): string | undefined { + const body = withoutFrontmatter(source).trim(); + return body.length === 0 ? undefined : body; +} + +/** The document after its frontmatter, if it opened with any. */ +function withoutFrontmatter(source: string): string { + if (!source.startsWith("---")) { + return source; + } + const end = source.indexOf("\n---", 3); + return end === -1 ? source : source.slice(source.indexOf("\n", end + 1) + 1); +} + /** What a component with no authored documentation renders instead of prose. */ export const NO_DOCUMENTATION = "No long-form documentation is available for this component."; @@ -114,10 +142,16 @@ export const NO_DOCUMENTATION = "No long-form documentation is available for thi const HEADING = /^##\s+(.+?)\s*$/; /** Any ATX heading, so a deeper one can be told from a section boundary. */ const ANY_HEADING = /^(#{1,6})\s+/; -/** A fence, so a heading inside a code block is code rather than a section. */ -const FENCE = /^\s*(```+|~~~+)/; -/** What a public component name may be: the same shape an element may write. */ -const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*$/; +/** + * A fence, with its marker captured whole. + * + * Both the character and the run length matter. A fence closes only on the same + * character at *least* as long as the one that opened it, so an example written + * in four backticks can contain a three-backtick block without the inner one + * ending the outer. Comparing only the character would end the example early and + * read everything after it as documentation. + */ +const FENCE = /^\s{0,3}(`{3,}|~{3,})/; /** One source, parsed into the bundle's own prose and a section per component. */ interface ParsedSource { @@ -138,15 +172,17 @@ export function parseDocumentationSource(source: DocumentationSource): ParsedSou const sections = new Map(); const bundle: string[] = []; let current: string[] = bundle; + /** The fence currently open, as the exact marker that opened it. */ let fence: string | undefined; for (const line of lines) { - const fenced = FENCE.exec(line); - if (fenced !== undefined && fenced !== null) { - const marker = fenced[1] ?? ""; + const marker = FENCE.exec(line)?.[1]; + if (marker !== undefined) { if (fence === undefined) { - fence = marker[0]; - } else if (marker.startsWith(fence)) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length) { + // Closes only on the same character, at least as long. A shorter run + // inside a longer fence is part of the example being shown. fence = undefined; } current.push(line); @@ -166,7 +202,11 @@ export function parseDocumentationSource(source: DocumentationSource): ParsedSou continue; } const name = heading[1] ?? ""; - if (!COMPONENT_NAME.test(name)) { + // The canonical grammar rather than a second copy of it, so a dotted name + // like `File.Delete` or `PullRequest.Reviews` can be documented and looked + // up. A private regex here would have quietly excluded every namespaced + // component in the product. + if (!isComponentName(name)) { throw new DocumentationIndexError( `${source.asset} has the level-two heading "${name}", which is not a component name. ` + "Every level-two heading in a component documentation file names one component.", diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 3c8e150c..f1ce750c 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -40,7 +40,7 @@ import { UnknownComponentError } from "./documentation-index.ts"; import type { WorkflowImportAuthority } from "./components/bundle.ts"; import type { DeclaredMarkdownComponent } from "./components/declared-markdown.ts"; import type { IdentityComponent } from "./invocation-identity.ts"; -import type { ComponentRegistry } from "./types.ts"; +import type { ComponentOrigin, ComponentRegistry } from "./types.ts"; /** * The catalog in scope for the segments being expanded. @@ -126,6 +126,34 @@ export function rootCatalogObservation( }; } +/** + * One catalog entry's identity: its name and its complete origin. + * + * Every member of the origin participates, not just its kind — a workflow blob + * differs from another by `sourceHash`, a declared component by `digest`, two + * registrations by their package and whether either is reserved. Comparing any + * less would let a component that merely resembles the admitted one report + * itself as admitted. + */ +function identityOf(entry: { name: string; origin: ComponentOrigin }): string { + const origin = entry.origin; + const parts: readonly string[] = + origin.kind === "structural" + ? [origin.construct] + : origin.kind === "repository" + ? [origin.path] + : origin.kind === "registered" + ? [origin.origin, String(origin.reserved)] + : origin.kind === "protected" + ? [origin.origin] + : origin.kind === "workflow" + ? [origin.path, origin.sourceHash] + : [origin.origin, origin.digest]; + // Length-prefixed, so no member's content can spell a separator and make two + // different identities collide. + return [entry.name, origin.kind, ...parts].map((part) => `${part.length}:${part}`).join(""); +} + /** * The selected entries, in catalog order, with their documentation and * availability. @@ -142,8 +170,14 @@ export function select( index: DocumentationIndex, ): SelectedEntry[] { const requested = new Set(names); + // Keyed by identity, not by name. A name is a spelling, and the whole point of + // the two inputs is that the enclosing catalog may hold a *different* + // component under the same one: an authoring entry for the built-in `Elicit` + // beside an admitted repository `Elicit.md` is two components. Reporting the + // reference entry as available because something called `Elicit` can run + // would tell an author they may execute the thing they were just shown. const runnable = new Set( - executable.categories.flatMap((category) => category.entries.map((entry) => entry.name)), + executable.categories.flatMap((category) => category.entries.map(identityOf)), ); const selected: SelectedEntry[] = []; // Walked in catalog order rather than request order, so two documents asking @@ -158,7 +192,7 @@ export function select( selected.push({ entry, documentation: index.documentationFor(entry.name, entry.origin), - available: runnable.has(entry.name), + available: runnable.has(identityOf(entry)), }); } } diff --git a/packages/core/tests/documentation-index.test.ts b/packages/core/tests/documentation-index.test.ts index d348fe21..2207dfab 100644 --- a/packages/core/tests/documentation-index.test.ts +++ b/packages/core/tests/documentation-index.test.ts @@ -84,6 +84,44 @@ describe("Tier SYN — parsing one documentation file", () => { expect(parsed.sections.get("Alpha")).toContain("## Beta"); }); + it("SYN33b: keeps a shorter fence inside a longer one as example text", function* () { + const parsed = parseDocumentationSource( + source( + [ + "## Alpha", + "", + "How to write a fenced example:", + "", + "````md", + "```mdx", + "", + "```", + "", + "## Beta", + "````", + "", + "Still Alpha.", + "", + ].join("\n"), + ), + ); + + // The inner three-backtick run neither opens nor closes anything: the outer + // four-backtick fence is still open, so `## Beta` inside it is the example + // it is being shown as. Closing on the character alone would have ended the + // example early and read the rest as a second component. + expect([...parsed.sections.keys()]).toEqual(["Alpha"]); + expect(parsed.sections.get("Alpha")).toContain("## Beta"); + expect(parsed.sections.get("Alpha")).toContain("Still Alpha."); + }); + + it("SYN33c: accepts a dotted component name as a heading", function* () { + const parsed = parseDocumentationSource(source("## File.Delete\n\nAbout File.Delete.\n")); + // The canonical grammar, so every namespaced component in the product can + // be documented and looked up. + expect(parsed.sections.get("File.Delete")).toBe("About File.Delete."); + }); + it("SYN34: refuses a duplicate section and a heading that is not a name", function* () { expect(() => parseDocumentationSource(source(["## Alpha", "one", "", "## Alpha", "two", ""].join("\n"))), diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 5c356a79..95ddbc1f 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -52,7 +52,10 @@ import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; import { fixedCatalogObservation } from "../src/syntax-observation.ts"; import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; -import type { FunctionComponent, SyntaxCatalog } from "../mod.ts"; +import type { ComponentOrigin, FunctionComponent, SyntaxCatalog } from "../mod.ts"; + +/** An origin a catalog *component* entry can carry — everything but structural. */ +type NamedOrigin = Exclude; const ROOT_PATH = "documents/root.md"; @@ -1031,6 +1034,95 @@ describe("Tier SYN — observation is never authority", () => { expect(yield* open.document(["Withheld"])).toContain("**Available in this evaluation:** yes"); }); + it("SYN25d: availability compares the whole identity, not the spelling", function* () { + /** One catalog holding a single entry of exactly this identity. */ + const holding = (origin: NamedOrigin): SyntaxCatalog => ({ + version: 2, + categories: [ + { kind: "structural", entries: [] }, + { + kind: "built-in", + entries: [ + { + kind: "component" as const, + name: "Elicit", + origin, + sourceKind: "registered" as const, + inspectability: "complete" as const, + forms: ["self-closing" as const], + props: { type: "object", properties: {}, additionalProperties: false }, + captures: [], + returnMode: "text" as const, + returns: { type: "string" }, + }, + ], + }, + { kind: "user-provided", entries: [] }, + ], + }); + + const reference: NamedOrigin = { + kind: "registered", + origin: "@executablemd/core", + reserved: false, + }; + + // Each of these is a *different component* that happens to be spelled + // `Elicit`. Reporting the reference entry as available because something of + // that name can run would tell an author they may execute what they were + // just shown. + const impostors: Record = { + "another registered origin": { + kind: "registered", + origin: "@someone/else", + reserved: false, + }, + "a reserved registration of the same origin": { + kind: "registered", + origin: "@executablemd/core", + reserved: true, + }, + "a repository file": { kind: "repository", path: "components/Elicit.md" }, + "a bundled blob": { + kind: "workflow", + path: "components/Elicit.md", + sourceHash: "a".repeat(40), + }, + "declared Markdown": { + kind: "declared-markdown", + origin: "@executablemd/core", + digest: "b".repeat(64), + }, + }; + + for (const [what, origin] of Object.entries(impostors)) { + const observation = fixedCatalogObservation(holding(origin), holding(reference)); + const rendered = yield* observation.document(["Elicit"]); + expect([what, rendered.includes("**Available in this evaluation:** no")]).toEqual([ + what, + true, + ]); + } + + // Two more of the same kind, differing only in the member that identifies + // them: a different blob under one path, and a different digest under one + // origin. + const bundled: NamedOrigin = { + kind: "workflow", + path: "components/Elicit.md", + sourceHash: "a".repeat(40), + }; + const moved: NamedOrigin = { ...bundled, sourceHash: "c".repeat(40) }; + expect( + yield* fixedCatalogObservation(holding(moved), holding(bundled)).document(["Elicit"]), + ).toContain("**Available in this evaluation:** no"); + + // The positive control: one exact identity, admitted. + expect( + yield* fixedCatalogObservation(holding(reference), holding(reference)).document(["Elicit"]), + ).toContain("**Available in this evaluation:** yes"); + }); + it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () { // `execute()` driven directly still carries one, so the case that has none // is an expansion driven outside an execution — which is what a component diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 672214a5..76528876 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10895,7 +10895,8 @@ component that observes one at an authored site. | SX4–SX6 | Renderers take a value | Both formats render from a supplied catalog with the filesystem refusing every call, twice with identical bytes, under the fixed category headings; every table cell is escaped, a prop name holding a pipe included | | SX7/SX8 | Includes | Repeated values select in caller order and replace the defaults; absent, the defaults apply | | SX9 | Failure | An unusable include exits 1, reports on stderr and prints no catalog | -| SX10/SX11 | Formats | Markdown by default, version-1 JSON with `--json`; the catalog is inspection, and `xmd plan` is the command that writes with the same structured value | +| SX10/SX11 | Formats | Markdown by default, version-2 JSON with `--json`; the catalog is inspection, and `xmd plan` is the command that writes with the same structured value | +| SX16 | Named lookup | `xmd syntax Elicit` renders that component's metadata and long-form documentation through the same selection, index and renderer `` uses; the compact catalog is unchanged and an unknown name refuses whole | | SX12 | A package tree | Bare `xmd syntax` succeeds with the default includes in a repository whose `node_modules` holds directory links | | SX13–SX15 | Delivery | A real pipeline reading a catalog larger than one pipe buffer receives the bytes a regular-file redirect receives, in both forms; a consumer that closes early leaves the command reporting on stderr with exit 1 rather than an unhandled write failure | From 63a79ddf27b6999b394ab2c8158abd24742aac0a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 00:06:20 -0400 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=93=9A=20Document=20every=20compone?= =?UTF-8?q?nt=20core=20supplies,=20and=20protect=20the=20assets=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The read was reachable from a document.** Last round I moved the package asset read onto the runtime `readTextFile`, reasoning it was the trusted path the root document's own read uses. It is `API.Fs`, which a running document can compose around — so a repository component, an eval block or an installed handler could answer the read and decide what the product says about itself. It now goes through the direct Effection filesystem, which no document-scoped middleware sits in front of. SYN40 plants `API.Fs` middleware around a repository component that wraps the named form and proves the canonical prose survives and the read never reaches that Api; routing it back through `API.Fs` fails that case. **Coverage is exact.** A first-party package now documents every component it supplies: a missing section refuses the whole index, as an unknown heading and a duplicate already did. A partially documented package is not a valid build, because a reader cannot tell an undocumented component from one with nothing to say. SYN38 is replaced accordingly — deleting any one built-in's section fails, with a fully covered package as the positive control. So core is complete: the ten components it registered without documentation, and the seven Agent components, each with a section written against its actual contract rather than its catalog line. The Agent boundary keeps its own `components.md` beside `agent/components.ts`. **The assembly is real.** `DocumentationContribution` was scaffolding nothing used; the run profile now contributes the Agent boundary beside the registry it installs, from the same declarations, captured before any document code exists. Sets merge per owner rather than replacing — core has two boundaries under one origin, and keying by owner alone made the second hide the first, which is what made `xmd syntax Elicit` refuse until it was fixed. **Three parser corrections.** The heading grammar is `isComponentName()` rather than a private regex, so `File.Delete` — a real core component — can be documented at all. A closing fence must be the same character, at least as long as the opener, and carry only trailing whitespace, so a same-length delimiter followed by text stays inside a longer example. Markdown-backed documentation is split by the canonical frontmatter parser rather than a delimiter search. **`names` selects components.** A structural construct is not one, so `` refuses; the fallback sentence is proved with an undocumented repository component, which is the case it is actually for. Both assets ship through source, npm and compiled layouts. --- deno.json | 2 +- packages/cli/src/syntax.ts | 22 ++- packages/core/mod.ts | 3 +- packages/core/src/agent/components.md | 127 +++++++++++++ packages/core/src/component-documentation.ts | 85 +++++++-- packages/core/src/components/components.md | 168 +++++++++++++++++- packages/core/src/definition.ts | 2 +- packages/core/src/documentation-index.ts | 66 ++++--- packages/core/src/syntax-observation.ts | 6 +- .../core/tests/documentation-index.test.ts | 29 ++- packages/core/tests/syntax-component.test.ts | 67 ++++++- scripts/build-npm.ts | 10 +- specs/executable-mdx-spec.md | 30 +++- 13 files changed, 557 insertions(+), 60 deletions(-) create mode 100644 packages/core/src/agent/components.md diff --git a/deno.json b/deno.json index 5908e276..a1311041 100644 --- a/deno.json +++ b/deno.json @@ -58,7 +58,7 @@ "verify:clean": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/verify-clean.ts", "deps": "deno run --allow-all scripts/deps.ts", "deps:target": "deno run --allow-all scripts/deps-target.ts", - "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --include packages/core/src/components/components.md --output dist/xmd packages/cli/src/compiled.ts", + "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --include packages/core/src/components/components.md --include packages/core/src/agent/components.md --output dist/xmd packages/cli/src/compiled.ts", "build:web": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/build-web-client.ts", "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 69525bdc..1eabc12c 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -20,6 +20,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { AGENT_REGISTRATIONS, + agentDocumentation, agentIdentityComponents, documentationIndexFor, inspectSyntax, @@ -28,7 +29,7 @@ import { renderSyntaxMarkdown, selectDocumented, } from "@executablemd/core"; -import type { SyntaxCatalog } from "@executablemd/core"; +import type { DocumentationContribution, SyntaxCatalog } from "@executablemd/core"; import { TESTING_REGISTRATIONS } from "@executablemd/testing"; import { WEB_REGISTRATIONS } from "@executablemd/web"; import { VERBOSE_REGISTRATION } from "./verbose-component.ts"; @@ -92,6 +93,23 @@ export function* useRunProfileRegistry(): Operation { ]); } +/** + * The documentation the `run` profile's own packages contribute. + * + * Assembled beside `useRunProfileRegistry()` and from the same declarations, so + * a package whose components this profile registers is a package whose + * documentation this profile demands. Core's own is added by + * `documentationIndexFor()`; everything here is a boundary outside it. + * + * The list is deliberately not "whatever is installed": it is captured at the + * trusted boundary, before any document code exists, so nothing a running + * document reaches can add a source, remove one, or answer for what a component + * does. + */ +export function* runProfileDocumentation(): Operation { + return [yield* agentDocumentation()]; +} + /** * The catalog as JSON: two-space indent, one trailing newline. * @@ -117,6 +135,6 @@ export function* renderSyntaxDocumentation( catalog: SyntaxCatalog, names: readonly string[], ): Operation { - const index = yield* documentationIndexFor(); + const index = yield* documentationIndexFor(yield* runProfileDocumentation()); return renderSelectedDocumentation(selectDocumented(catalog, catalog, names, index)); } diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 6be49370..c5362f1e 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -226,7 +226,8 @@ export type { SelectedEntry } from "./src/syntax-markdown.ts"; * lookup rather than two that agree by hand: the command reaches the index and * the selection core's own component reaches. */ -export { documentationIndexFor } from "./src/component-documentation.ts"; +export { agentDocumentation, documentationIndexFor } from "./src/component-documentation.ts"; +export type { DocumentationContribution } from "./src/component-documentation.ts"; export { select as selectDocumented } from "./src/syntax-observation.ts"; export { NO_DOCUMENTATION, UnknownComponentError } from "./src/documentation-index.ts"; export type { DocumentationIndex } from "./src/documentation-index.ts"; diff --git a/packages/core/src/agent/components.md b/packages/core/src/agent/components.md new file mode 100644 index 00000000..e3f62327 --- /dev/null +++ b/packages/core/src/agent/components.md @@ -0,0 +1,127 @@ +Long-form documentation for the Agent components canonical core registers. + +These are the components a document uses to talk to a coding agent: which agent +and session to use, how to prompt it, how to answer the permission requests it +makes, and how to hand it the terminal. They register under +`@executablemd/core`, beside the components in `../components/components.md`, +and are documented here because this is the registration boundary they belong +to. + +Most of them are *regions*: they establish something for the content inside them +and restore what was there on the way out. That is what lets one document use two +agents, or grant broad permission for one narrow step without granting it +everywhere. + +## AgentProvider + +Sets the agent provider for its content. + +```mdx + +… + +``` + +Applies to everything inside. `defaultAgent` names the agent to use when a +`` or `` inside does not name one, and `timeout` bounds each +prompt rather than the region as a whole. + +An unknown provider fails **before** the content runs, so a document that names a +provider this host does not have stops rather than doing half its work and then +discovering it cannot finish. + +## Agent + +Chooses the agent for the prompts and launches inside it. + +```mdx + +Summarise the release notes. + +``` + +A region, like the provider above it: the content's prompts use this agent, and +what was in force outside is restored afterwards. Use it when one document needs +more than one agent — a fast one for a mechanical pass, a stronger one for the +judgement call. + +## Session + +Sets the default session for every prompt in its content. + +```mdx + +What changed since the last tag? +Which of those need release notes? + +``` + +Prompts in one session share the agent's context, so a later prompt can refer to +what an earlier one established. Without a session each prompt stands alone, +which is what you want for independent questions and not what you want for a +conversation. + +The session is durable: a run that resumes rejoins the session it was using +rather than starting a fresh one and losing the context the document built. + +## Session.Launch + +Launches a coding agent with prepared context, in the terminal. + +```mdx + +Here is the failing test and what I have tried. + +``` + +The content becomes the agent's starting context. The agent's own interface then +takes the terminal — this is the interactive agent, not a prompt-and-reply — and +the document continues when you are done with it. + +Reach for it when the work needs a person and an agent together, and for +prepared context that would be tedious to type. + +## Prompt + +Sends a prompt and renders the reply. + +```mdx +Summarise these release notes in three bullets. +``` + +The paired form sends its content. The reply is rendered where the element is +written, or bound with `as`. Props naming an agent, session or timeout override +the surrounding scope for this one prompt. + +A failed prompt renders what it got and the document continues, because a +partial answer is usually more useful than none. `throwOnError` stops the +document instead, for a prompt whose answer everything after it depends on. + +## ApproveAll + +Approves every permission request its content makes. + +```mdx + +Fix the failing test. + +``` + +A region, and deliberately a narrow one: it is how a document says *this step is +allowed to act without asking*, for exactly this step. Wrapping a whole document +in it grants far more than any single step needed, so wrap the step. + +## AskPermission + +Puts every permission request its content makes to you. + +```mdx + +Clean up the scratch directory. + +``` + +The opposite region to ``: each request is asked about rather than +granted. With no interactive terminal, or no valid answer, it **denies** — the +safe direction, so a document that runs unattended does not silently do what it +would have asked about. diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index 31b1ebf7..c0182bab 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -14,20 +14,55 @@ * rather than quietly serving a product with no documentation. */ -import { fileURLToPath } from "node:url"; -import { readTextFile } from "@executablemd/runtime"; +import { readTextFile } from "@effectionx/fs"; import type { Operation } from "effection"; import { buildDocumentationIndex } from "./documentation-index.ts"; import type { DocumentationIndex, DocumentationSource } from "./documentation-index.ts"; import { CORE_ORIGIN, CORE_REGISTRY } from "./components/registry.ts"; import { PROTECTED_COMPONENT_NAMES } from "./components/protected.ts"; +import { AGENT_REGISTRATIONS, agentIdentityComponents } from "./agent/components.ts"; /** Where core's own documentation lives, as a URL beside this module. */ export function componentDocumentationUrl(): URL { return new URL("./components/components.md", import.meta.url); } +/** + * Where the Agent registrations' documentation lives. + * + * Beside `agent/components.ts`, which is the boundary that registers them. They + * carry core's origin, so they are core's components for the join; what makes + * them a separate file is that they are a separate registration boundary, and + * documentation belongs beside the code it documents. + */ +export function agentDocumentationUrl(): URL { + return new URL("./agent/components.md", import.meta.url); +} + +/** + * The Agent registrations' contribution, for a host that installs them. + * + * Offered rather than assumed: a run that registers no Agent components has no + * Agent components to document, and demanding their documentation would refuse + * an index for a profile that is complete without them. + */ +export function* agentDocumentation(): Operation { + return { + source: yield* readPackagedDocumentation(agentDocumentationUrl(), { + owner: CORE_ORIGIN, + asset: "packages/core/src/agent/components.md", + }), + supplies: AGENT_COMPONENT_NAMES, + }; +} + +/** Every component the Agent registration boundary supplies, by name. */ +const AGENT_COMPONENT_NAMES: ReadonlySet = new Set([ + ...AGENT_REGISTRATIONS.map((registration) => registration.name), + ...agentIdentityComponents().map((component) => component.name), +]); + /** Core's documentation source, read from the package rather than the caller. */ export function* readCoreDocumentation(): Operation { const url = componentDocumentationUrl(); @@ -35,13 +70,15 @@ export function* readCoreDocumentation(): Operation { return { owner: CORE_ORIGIN, asset: "packages/core/src/components/components.md", - // The host filesystem operation the root document's own read goes - // through, not the document-facing `Files` authority: this is the engine - // reading its own package, and what a running document installed must not - // decide what its documentation says. The path is derived from this - // module's URL, so it is package-relative whatever the working directory - // and search path are. - text: yield* readTextFile(fileURLToPath(url)), + // The direct Effection filesystem, not `API.Fs` and not the document + // facing `Files` authority. Both of those are middleware a running + // document can compose around: a repository component, an eval block or + // an installed handler could answer the read and decide what the product's + // own documentation says. This is the engine reading an immutable asset + // out of its own package, so it goes to the filesystem directly, at a URL + // derived from this module — package-relative whatever the working + // directory and search path are. + text: yield* readTextFile(url), }; } catch (error) { throw new Error( @@ -51,6 +88,22 @@ export function* readCoreDocumentation(): Operation { } } +/** One packaged documentation asset, read the same guarded way. */ +export function* readPackagedDocumentation( + url: URL, + named: { owner: string; asset: string }, +): Operation { + try { + return { ...named, text: yield* readTextFile(url) }; + } catch (error) { + throw new Error( + `the packaged component documentation ${named.asset} is missing from this build ` + + `(looked in ${url.href})`, + { cause: error }, + ); + } +} + /** * The index every documentation reader shares. * @@ -80,7 +133,19 @@ export function* documentationIndexFor( supplies: CORE_COMPONENT_NAMES, }; const all = [core, ...contributed]; - const supplied = new Map(all.map((one) => [one.source.owner, one.supplies])); + // Merged per owner, not replaced. One package can have several registration + // boundaries — core registers its own components and its Agent components + // from two files — and keying by owner alone would let the second boundary's + // set hide the first's, so every component in the file that lost would look + // like documentation for something the package does not supply. + const supplied = new Map>(); + for (const one of all) { + const held = supplied.get(one.source.owner) ?? new Set(); + for (const name of one.supplies) { + held.add(name); + } + supplied.set(one.source.owner, held); + } return buildDocumentationIndex( all.map((one) => one.source), (owner) => supplied.get(owner) ?? new Set(), diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index b815539a..60c8e3c2 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -6,8 +6,11 @@ forms, props and one-line description. This file holds the part that does not belong in a list: when to reach for a component, what it does at run time, and what it will refuse. -A component with no section here is still ordinary and still usable. Selecting -it by name reports that no long-form documentation is available for it yet. +Every component this package supplies has a section here. A build in which one +does not refuses rather than serving a reference with a silent hole in it: a +reader cannot tell "nobody has written this yet" from "this component has +nothing to say". The no-documentation sentence is for *custom* components, which +no package governs. ## Syntax @@ -110,3 +113,164 @@ read rather than re-reading a file that has since changed. A read of a path that does not exist fails. The write form creates the file and the directories above it as needed. + +## File.Delete + +Deletes a file, relative to the working directory. + +```mdx + +``` + +Self-closing, and it renders nothing. Deleting a path that does not exist +succeeds: the component's promise is that the file is gone afterwards, not that +it was there first, which is what makes it safe to write in a cleanup step that +may run more than once. + +Like ``, it is an ordinary durable effect — a deletion that already +happened is not repeated on a continuation. + +## TempDir + +Runs work in a temporary working directory. + +```mdx + +working notes + +``` + +The paired form expands its content with the temporary directory as the working +directory, so a `` or a command inside writes there rather than in the +directory the run started in. The self-closing form renders the path instead, +which is what you want when something outside the region needs to know where it +is: + +```mdx + +``` + +Use it to keep intermediate work out of the user's tree, and to make a document +that writes files safe to run from anywhere. + +## Fail + +Stops authored work with an actionable failure. + +```mdx + +``` + +Raises the message where it is written. It is the authored counterpart of an +error a component raises on its own: the document has decided that what it found +is not something it can proceed from, and says so in its own words rather than +letting a later step fail obscurely. + +The message is the whole point — write what a reader would need in order to act, +not that something went wrong. + +## Fetch + +Reads over HTTP. + +```mdx + +``` + +Only GET and HEAD are currently supported. Without `as`, a non-2xx status fails +the document. With `as`, the response binds instead — status included — which is +what makes a status something to branch on rather than an error: + +```mdx + + + +Nothing published yet. + +``` + +The request is journaled, so a continuation restores what the first run received +rather than asking the network again. + +## Glob + +Lists the files matching a pattern, relative to the working directory. + +```mdx + +``` + +`as` is required: the component's result is the list, and there is no useful +text to render. The list is sorted, so a document that iterates it produces the +same output for the same tree. Directories and symbolic links are never results +— only files. + +## CodeBlock + +Shows arbitrary text as a fenced Markdown code block. + +```mdx +{payload} +``` + +Use it when a value is going into a document that will be read as Markdown and +must not be interpreted as Markdown: a fragment containing backticks, a diff, or +anything an agent might otherwise read as instructions. It renders the fence for +you, with a delimiter long enough to survive whatever the content contains. + +## Json + +Renders a value as JSON text. + +```mdx + +``` + +Writes the JSON where the element is written, or binds it with `as`. The +counterpart of ``: this turns a value into text, that turns text into a +value. + +## Parse + +Parses JSON text against a schema, and errors on invalid content. + +```mdx +{raw} +``` + +The content is the JSON text. `as` is required, because the parsed value is the +result. Invalid content — malformed JSON, or JSON the schema rejects — fails the +document, which is what you want when there is nothing sensible to do without +the value. + +Use `` instead when the document should decide what to do about +invalid input. + +## SafeParse + +Parses JSON text against a schema, and returns a result object instead of +failing. + +```mdx +{raw} +``` + +The bound result is either the validated value or the issues that rejected it, +so the document can branch on which it got. Reach for this when invalid input is +an expected case — reading something a person typed, or a response from a +service that may be having a bad day — rather than a reason to stop. + +## Test + +Declares a test case. + +```mdx + +… + +``` + +It runs only inside a `` region; elsewhere it is skipped, so a document +carrying its own tests stays runnable as an ordinary document. A failing command +or assertion inside the case fails that case rather than the whole run, which is +what lets one run report every failure rather than only the first. diff --git a/packages/core/src/definition.ts b/packages/core/src/definition.ts index b88af713..cef19291 100644 --- a/packages/core/src/definition.ts +++ b/packages/core/src/definition.ts @@ -36,7 +36,7 @@ interface ParsedSource { * gray-matter normalization surprise into a loud error instead of silently * wrong source positions. */ -function parseSource(path: string, content: string): ParsedSource { +export function parseSource(path: string, content: string): ParsedSource { const parsed = matter(content); const baseOffset = content.length - parsed.content.length; if (content.slice(baseOffset) !== parsed.content) { diff --git a/packages/core/src/documentation-index.ts b/packages/core/src/documentation-index.ts index ac23df7f..0b9e9513 100644 --- a/packages/core/src/documentation-index.ts +++ b/packages/core/src/documentation-index.ts @@ -45,6 +45,7 @@ */ import { isComponentName } from "./components/registration.ts"; +import { parseSource } from "./definition.ts"; import type { ComponentOrigin } from "./types.ts"; /** One package's documentation for the components it registers. */ @@ -121,20 +122,15 @@ export interface DocumentationIndex { * repository component's bytes were read to describe it, and a bundled or * declared one's were admitted before the run began. */ -export function markdownDocumentation(source: string): string | undefined { - const body = withoutFrontmatter(source).trim(); +export function markdownDocumentation(path: string, source: string): string | undefined { + // The canonical splitter, not a delimiter search of this module's own: a + // document whose body repeats `---` is ordinary, and a second implementation + // of the rule is one release from disagreeing with the one that decides what + // actually runs. + const body = parseSource(path, source).content.trim(); return body.length === 0 ? undefined : body; } -/** The document after its frontmatter, if it opened with any. */ -function withoutFrontmatter(source: string): string { - if (!source.startsWith("---")) { - return source; - } - const end = source.indexOf("\n---", 3); - return end === -1 ? source : source.slice(source.indexOf("\n", end + 1) + 1); -} - /** What a component with no authored documentation renders instead of prose. */ export const NO_DOCUMENTATION = "No long-form documentation is available for this component."; @@ -143,15 +139,17 @@ const HEADING = /^##\s+(.+?)\s*$/; /** Any ATX heading, so a deeper one can be told from a section boundary. */ const ANY_HEADING = /^(#{1,6})\s+/; /** - * A fence, with its marker captured whole. + * A fence line: its delimiter run, and whatever follows on the line. * - * Both the character and the run length matter. A fence closes only on the same - * character at *least* as long as the one that opened it, so an example written - * in four backticks can contain a three-backtick block without the inner one - * ending the outer. Comparing only the character would end the example early and - * read everything after it as documentation. + * Three rules decide whether a line ends the block it is in, and getting any of + * them wrong reads an example as documentation. The closing run must be the same + * character, at *least* as long as the opener — so a four-backtick example may + * contain a three-backtick block — and it must carry nothing after it but + * whitespace. An *opening* fence may carry an info string (` ```mdx `); a + * closing one may not, so a same-length delimiter followed by text is still + * inside the example. */ -const FENCE = /^\s{0,3}(`{3,}|~{3,})/; +const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; /** One source, parsed into the bundle's own prose and a section per component. */ interface ParsedSource { @@ -176,13 +174,18 @@ export function parseDocumentationSource(source: DocumentationSource): ParsedSou let fence: string | undefined; for (const line of lines) { - const marker = FENCE.exec(line)?.[1]; - if (marker !== undefined) { + const fenced = FENCE.exec(line); + if (fenced !== null) { + const marker = fenced[1] ?? ""; + const trailing = fenced[2] ?? ""; if (fence === undefined) { + // Opening: the info string is allowed and ignored. fence = marker; - } else if (marker[0] === fence[0] && marker.length >= fence.length) { - // Closes only on the same character, at least as long. A shorter run - // inside a longer fence is part of the example being shown. + } else if ( + marker[0] === fence[0] && + marker.length >= fence.length && + trailing.trim().length === 0 + ) { fence = undefined; } current.push(line); @@ -273,6 +276,23 @@ export function buildDocumentationIndex( } } + // Exact coverage, not merely no surprises. A first-party package documents + // every public component it supplies: a member with no section is a component + // shipped without documentation, and letting it fall back to the sentence + // would make the product's own reference silently incomplete — the reader + // cannot tell "nobody has written this yet" from "this component has nothing + // to say". The fallback is for custom components, which no package governs. + for (const [owner, held] of documentation) { + const missing = [...known(owner)].filter((name) => !held.has(name)).sort(); + if (missing.length > 0) { + throw new DocumentationIndexError( + `${owner} supplies ${missing.length === 1 ? "a component" : "components"} with no ` + + `documentation: ${missing.join(", ")}. Every public component a first-party ` + + "package supplies has exactly one documentation section.", + ); + } + } + return { documentationFor(name: string, origin: ComponentOrigin): string | undefined { const owner = owningPackage(origin); diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index f1ce750c..3fe47f59 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -185,7 +185,11 @@ export function select( // what makes one occurrence's retained result comparable with another's. for (const category of reference.categories) { for (const entry of category.entries) { - if (!requested.has(entry.name)) { + // Components only. `names` is a component lookup under the current + // contract, so a structural construct is not a thing this can select — + // and skipping it here leaves the name in `requested`, which refuses + // below rather than silently rendering nothing for it. + if (!requested.has(entry.name) || entry.kind === "structural") { continue; } requested.delete(entry.name); diff --git a/packages/core/tests/documentation-index.test.ts b/packages/core/tests/documentation-index.test.ts index 2207dfab..6bbc830e 100644 --- a/packages/core/tests/documentation-index.test.ts +++ b/packages/core/tests/documentation-index.test.ts @@ -168,14 +168,29 @@ describe("Tier SYN — building the index", () => { expect(index.documentationFor("Beta", REGISTERED)).toBeUndefined(); }); - it("SYN38: builds from a set that documents only some of what it supplies", function* () { - // A component with no section is legal: it renders the sentence `` - // states for one, and stays usable while its documentation is written. - const index = buildDocumentationIndex( - [source("## Alpha\n\nAbout Alpha.\n")], + it("SYN38: refuses a package that supplies a component it does not document", function* () { + // The negative control for coverage. A first-party package documents every + // public component it supplies, so a missing section refuses the whole + // index rather than letting that component fall back to the sentence — + // which would leave the product's own reference silently incomplete, with + // no way for a reader to tell an undocumented component from one that has + // nothing to say. + expect(() => + buildDocumentationIndex([source("## Alpha\n\nAbout Alpha.\n")], supplies("Alpha", "Beta")), + ).toThrow(DocumentationIndexError); + + // Deleting any one built-in's documentation is the same failure, which is + // what makes this a live check on the shipped files rather than a rule + // nothing enforces. + expect(() => + buildDocumentationIndex([source("Bundle prose only.\n")], supplies("Alpha")), + ).toThrow(DocumentationIndexError); + + // The positive control: exact coverage builds. + const complete = buildDocumentationIndex( + [source("## Alpha\n\nAbout Alpha.\n\n## Beta\n\nAbout Beta.\n")], supplies("Alpha", "Beta"), ); - expect(index.documentationFor("Alpha", REGISTERED)).toBe("About Alpha."); - expect(index.documentationFor("Beta", REGISTERED)).toBeUndefined(); + expect(complete.documentationFor("Beta", REGISTERED)).toBe("About Beta."); }); }); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 95ddbc1f..478eeb19 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -299,11 +299,68 @@ describe("Tier SYN — the named form", () => { // A component core supplies but has not documented yet renders its // metadata and says the documentation is missing, rather than refusing. - // A structural construct comes from no package at all, so no package-owned - // documentation can ever be its — the join has nothing to match on. - const undocumented = String(yield* run('\n')); - expect(undocumented).toContain("### ``"); - expect(undocumented).toContain("No long-form documentation is available for this component."); + // A custom component nothing documents: a repository file, which no + // first-party package governs, so the fallback is the honest answer rather + // than a hole in the product's own reference. + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile(join(dir, "Homegrown.md"), "a component of my own\n"); + const undocumented = String( + yield* run('\n', [], undefined, [dir]), + ); + expect(undocumented).toContain("### ``"); + expect(undocumented).toContain("No long-form documentation is available for this component."); + }); + + // A structural construct is not a component, and `names` is a component + // lookup: it refuses rather than rendering one. + const structural = yield* refusal(run('\n')); + expect(structural).toContain("If"); + }); + + it("SYN40: documentation survives planted filesystem middleware", function* () { + const asked: string[] = []; + const canonical = String(yield* run('\n')); + expect(canonical).toContain("Asks a person a structured question"); + + // A repository component that wraps the named form, with `API.Fs` middleware + // planted around it. If package documentation were read through the + // filesystem Api a document can compose — or through the `Files` authority — + // this would answer for the product's own reference, and an agent could be + // handed instructions the product never wrote. + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile( + join(dir, "Wrapper.md"), + ['', ""].join("\n"), + ); + const output = String( + yield* scoped(function* () { + yield* API.Fs.around({ + *readTextFile([path], next) { + asked.push(String(path)); + if (String(path).endsWith("components.md")) { + return "## Elicit\n\nSUBSTITUTED BY A DOCUMENT.\n"; + } + return yield* next(path); + }, + }); + return yield* collect( + yield* executeInstalled( + { + ...retainedSource(ROOT_PATH, "\n"), + stream: new InMemoryStream(), + includes: [dir], + }, + [], + ), + ); + }), + ); + + expect(output).toContain("Asks a person a structured question"); + expect(output).not.toContain("SUBSTITUTED BY A DOCUMENT"); + // And the read never went through that Api at all, which is why. + expect(asked.some((path) => path.endsWith("components.md"))).toBe(false); + }); }); it("SYN39: retains the named text, and a continuation restores it whole", function* () { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 38a1e270..b5064d54 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -57,9 +57,13 @@ function* packagedDocuments(pkgDir: URL): Operation { // stay in step. Named by its exact path for the same reason the directory // above is enumerated rather than swept for: being listed here is what // declares an asset part of the product. - const documentation = new URL("src/components/components.md", pkgDir); - if (yield* exists(documentation)) { - shipped.push("src/components/components.md"); + // One entry per registration boundary that documents its components. Named + // rather than swept for, because `src/` also holds test documents and + // scenario fixtures: being listed here is what declares an asset shipped. + for (const relative of ["src/components/components.md", "src/agent/components.md"]) { + if (yield* exists(new URL(relative, pkgDir))) { + shipped.push(relative); + } } const documents = new URL("src/documents/", pkgDir); if (!(yield* exists(documents))) { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 76528876..86bcaf1a 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2711,9 +2711,29 @@ order they were asked for in. `as` captures the same text in either form. Documentation joins to metadata by component name **and owning package**. Only a registration and a protected component come from a package; a repository file, a bundled blob and declared Markdown are this run's, so a repository `Elicit.md` -receives none of the built-in `Elicit`'s prose. A selected entry with no authored -documentation renders its metadata and the sentence *No long-form documentation -is available for this component.* rather than refusing. +receives none of the built-in `Elicit`'s prose. **A first-party package documents every component it supplies.** The index is +built from one `components.md` per registration boundary, contributed at the +trusted installation boundary, and a boundary that supplies a component with no +section refuses the whole index — as does an unknown heading, a duplicate, and a +component documented twice for one package. A partially documented first-party +package is not a valid build: a reader cannot tell an undocumented component +from one that has nothing to say, and the product's own reference would be +silently incomplete. + +The sentence *No long-form documentation is available for this component.* is +therefore for **custom** components only, which no package governs. A Markdown +component — a repository file, a bundle member, a host's declared Markdown — +takes its long-form documentation from its own document's body, read through the +canonical frontmatter split; an empty body uses the sentence. + +Named lookup selects **component entries**. A structural construct is not a +component, so `` refuses as an unknown component while +the bare form continues to list structural syntax alongside components. + +The documentation is read from the owning package through the direct filesystem, +not through `API.Fs` or the document-facing `Files` authority. Both of those are +middleware a running document can compose around, and a document that could +answer the read would decide what the product says about itself. **Reference and availability are separate.** The observation carries two inputs. Bare `` reports what may **execute** at this site. The named form @@ -10884,7 +10904,9 @@ component that observes one at an authored site. | SYN39 | Named retention | The occurrence retains its final rendered text, a continuation restores it without rereading documentation or rebuilding the catalog, and a corrupted record refuses | | SYN25c | The narrowing seam | A narrowed observation reports the narrowed vocabulary bare, documents the enclosing catalog by name, and marks each entry's availability truthfully in both directions | | SYN32–SYN34 | Parsing one file | Bundle prose, a section per level-two heading with deeper headings kept inside it, a fenced heading read as the example it is, and a refusal for a duplicate section or a heading that is not a component name | -| SYN35–SYN38 | Building the index | A heading naming something the package does not supply refuses; one component documented twice refuses; documentation attaches by name and owning package, never to a repository replacement; a partly documented package builds | +| SYN35–SYN37 | Building the index | A heading naming something the package does not supply refuses; one component documented twice refuses; documentation attaches by name and owning package, never to a repository replacement | +| SYN38 | Exact coverage | A package supplying a component it does not document refuses the whole index — deleting any one built-in's section fails — with a fully covered package as the positive control | +| SYN40 | The protection boundary | A repository component wrapping the named form with planted `API.Fs` middleware cannot change what the documentation says, and the read never reaches that Api | ### Tier SX — The `xmd syntax` command From 98087fd397afdc8c1697bc4da8b0f8530105d64e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 05:26:00 -0400 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=94=97=20Carry=20package=20document?= =?UTF-8?q?ation=20through=20the=20execution=20boundary=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xmd syntax Prompt` printed Prompt's documentation while `` answered with the fallback sentence. One product, two answers to one question, and the fallback was the wrong one: the command assembled the profile's contributions and the component reached a core-only index, because `documentationIndexFor()` defaulted to none and only the command passed anything. The contributions now travel the way the catalog beside them does — captured at the installation boundary before any document code exists, carried by value on `ExecutionInstallation.documentation`, and handed to the root observation. Several are ordinary rather than refused, unlike the single catalog: one registration boundary is one file, and a profile installing four packages has four. `fixedCatalogObservation` takes them too, so narrowing what may *execute* does not narrow what an author may read about — #713 still installs the executable catalog. Three documentation errors, each caught against the registration rather than by rereading the prose: - `` takes `include={["docs/**/*.md"]}`, a list of patterns, not a `pattern` string; - `` is self-closing with `value={…}`, and chooses a fence the value cannot break out of; - `` runs under `xmd test` *or* inside ``, not only the latter. That is the reviewer's point about prose review not being verification, and it is well taken: all three read plausibly and all three were wrong. --- packages/cli/src/cli.ts | 7 ++++ packages/cli/tests/syntax-cli.test.ts | 19 +++++++++++ packages/core/src/components/components.md | 39 +++++++++++++--------- packages/core/src/execute.ts | 34 +++++++++++++++++++ packages/core/src/syntax-observation.ts | 24 +++++++++++-- 5 files changed, 106 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 347cc9f4..fef88065 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -132,6 +132,7 @@ import { renderSyntaxDocumentation, renderSyntaxJson, renderSyntaxMarkdown, + runProfileDocumentation, syntaxCatalog, } from "./syntax.ts"; import { deliverWhole } from "./stdout-delivery.ts"; @@ -1171,6 +1172,12 @@ function* runDocument( // and does not gain `` at its root — but the production run child // it can launch is the run profile, and gets it below. ...(mode.testing ? {} : { declarations: [plan] }), + // The documentation the packages this profile registers ship, beside + // the registrations themselves. Without it a document's own + // `` would read a core-only index and answer with the + // fallback sentence for a component `xmd syntax NAME` documents fully — + // one product, two answers. + documentation: yield* runProfileDocumentation(), }, // The declarations a nested execution may configure a child with, named // by the exact definitions this command installed. Recognizing one is diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 863398dd..b9435d5a 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -494,6 +494,25 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(unknown.stdout).toBe(""); }); + it("SX17: the command and the component read one index, for every package", function* () { + // `Prompt` is an Agent component: a different registration boundary from + // core's own file, and the one that exposed this. The command assembled the + // profile's contributions while a document's own named form fell back to a + // core-only index, so one product answered the same question two ways. + const command = yield* runCli(["syntax", "Prompt", "--include", "."], { cwd: "." }).expect(); + expect(command.stdout).toContain("### ``"); + expect(command.stdout).toContain("Sends a prompt and renders the reply"); + + const document = yield* runCli( + ["run", "-e", '', "--include", "."], + { cwd: "." }, + ).expect(); + expect(document.stdout).toContain("Sends a prompt and renders the reply"); + expect(document.stdout).not.toContain( + "No long-form documentation is available for this component.", + ); + }); + it("SX10: writes markdown by default and version-2 JSON with --json", function* () { yield* useWorkspace(WORKSPACE, function* (cwd) { const markdown = yield* runCli(["syntax", "--include", "first"], { cwd }).expect(); diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index 60c8e3c2..b1766ff1 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -197,26 +197,32 @@ rather than asking the network again. Lists the files matching a pattern, relative to the working directory. ```mdx - + ``` -`as` is required: the component's result is the list, and there is no useful -text to render. The list is sorted, so a document that iterates it produces the -same output for the same tree. Directories and symbolic links are never results -— only files. +`include` is a list of patterns, so one element can gather several shapes of +path in one pass. `as` is required: the component's result is the list of +matched paths, and there is no useful text to render. + +The list is sorted, so a document that iterates it produces the same output for +the same tree. Directories and symbolic links are never results — only files. ## CodeBlock Shows arbitrary text as a fenced Markdown code block. ```mdx -{payload} + ``` -Use it when a value is going into a document that will be read as Markdown and -must not be interpreted as Markdown: a fragment containing backticks, a diff, or -anything an agent might otherwise read as instructions. It renders the fence for -you, with a delimiter long enough to survive whatever the content contains. +Self-closing: the text is the `value` prop rather than content. Use it when a +value is going into a document that will be read as Markdown and must not be +interpreted as Markdown — a fragment containing backticks, a diff, or anything +an agent might otherwise read as instructions. + +It chooses a fence the value cannot break out of, so a value that itself +contains fences is still shown rather than escaping into the surrounding +document. `as` captures the exact fenced Markdown instead of emitting it. ## Json @@ -265,12 +271,15 @@ service that may be having a bad day — rather than a reason to stop. Declares a test case. ```mdx - + ``` -It runs only inside a `` region; elsewhere it is skipped, so a document -carrying its own tests stays runnable as an ordinary document. A failing command -or assertion inside the case fails that case rather than the whole run, which is -what lets one run report every failure rather than only the first. +It runs under `xmd test`, or inside a `` region. Elsewhere it is +skipped, so a document carrying its own tests stays runnable as an ordinary +document — the tests are inert until something asks for them. + +A failing command or assertion inside the case fails that case rather than the +whole run, which is what lets one run report every failure rather than only the +first. diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 6961b503..892ef12a 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -141,6 +141,7 @@ import { ExecutionImports } from "./components/import-authority.ts"; import type { ExpansionAuthority, ImportTier } from "./components/import-authority.ts"; import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; import { rootCatalogObservation } from "./syntax-observation.ts"; +import type { DocumentationContribution } from "./component-documentation.ts"; import type { CatalogContribution } from "./syntax-observation.ts"; import type { WorkflowComponentBundle, WorkflowImportAuthority } from "./components/bundle.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; @@ -2152,6 +2153,14 @@ function* executeDocument( identityComponents: readonly IdentityComponent[] = [], declarations: readonly DeclaredMarkdownComponent[] = [], catalogs: readonly CatalogContribution[] = [], + /** + * The documentation each installed package contributes. + * + * Carried by value from the installation boundary, like the catalog beside + * it, so the index a document's own `` reads is the index + * the profile actually assembled. + */ + documentation: readonly DocumentationContribution[] = [], ): Operation { const { stream, @@ -2338,6 +2347,7 @@ function* executeDocument( ...(bundle === undefined ? {} : { workflow: bundle }), }, catalogs[0], + documentation, ), }; @@ -2628,6 +2638,19 @@ export interface ExecutionInstallation { * rather than the authorship execution's. One execution accepts one. */ readonly catalog?: CatalogContribution; + /** + * The long-form documentation this installation's packages ship. + * + * One entry per registration boundary that documents its components, derived + * from the same declarations the installation registers. Captured by value + * before any document code runs: a document that could add a contribution + * could describe components it does not have, and one that could remove a + * contribution could hide the documentation of a component it does. + * + * Several are ordinary, unlike `catalog` — a profile installing four packages + * has four boundaries — so they are collected rather than refused. + */ + readonly documentation?: readonly DocumentationContribution[]; install?(): Operation; } @@ -3029,6 +3052,16 @@ function* invoke( return catalog === undefined ? [] : [catalog]; }), ); + // The documentation each installed package contributes, captured here with + // the rest of the installation and carried by value. Unlike the catalog + // above, several are ordinary: one registration boundary is one file, and a + // profile that installs four packages has four. Collecting them here is what + // makes `` and `xmd syntax NAME` read one index — the + // component reached a core-only index before this, so an Agent component had + // documentation on the command line and the fallback sentence in a document. + const documentation = Object.freeze( + installations.flatMap((installation) => [...(installation.documentation ?? [])]), + ); if (catalogs.length > 1) { throw new Error( "two installations stated the catalog this execution describes. One execution describes " + @@ -3075,6 +3108,7 @@ function* invoke( identityComponents, declarations, catalogs, + documentation, ); } diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 3fe47f59..70e0c34a 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -35,6 +35,7 @@ import type { SyntaxCatalog } from "./inspect.ts"; import { renderSelectedDocumentation, renderSyntaxMarkdown } from "./syntax-markdown.ts"; import type { SelectedEntry } from "./syntax-markdown.ts"; import { documentationIndexFor } from "./component-documentation.ts"; +import type { DocumentationContribution } from "./component-documentation.ts"; import type { DocumentationIndex } from "./documentation-index.ts"; import { UnknownComponentError } from "./documentation-index.ts"; import type { WorkflowImportAuthority } from "./components/bundle.ts"; @@ -107,6 +108,15 @@ export interface CapturedCatalogInputs { export function rootCatalogObservation( inputs: CapturedCatalogInputs, contribution: CatalogContribution | undefined, + /** + * The documentation the installed packages contribute. + * + * Handed in rather than assumed, so a document's own named lookup reads the + * index its profile assembled. Defaulting to none is what made an Agent + * component answer with documentation on the command line and with the + * fallback sentence inside a document. + */ + documentation: readonly DocumentationContribution[] = [], ): CatalogObservation { function* current(): Operation { return contribution === undefined ? yield* derived(inputs) : yield* contribution(); @@ -120,7 +130,7 @@ export function rootCatalogObservation( // may execute, so what an author may read about and what they may run are // the same set, and every selected entry is available. const catalog = yield* current(); - const index = yield* documentationIndexFor(); + const index = yield* documentationIndexFor(documentation); return renderSelectedDocumentation(select(catalog, catalog, names, index)); }, }; @@ -247,6 +257,16 @@ export function fixedCatalogObservation( * the very components they are being asked to write about. */ reference: SyntaxCatalog = catalog, + /** + * The enclosing execution's documentation contributions, carried across the + * seam. + * + * Narrowing what may *execute* does not narrow what an author may read about: + * the enclosing authoring documentation travels in with the enclosing + * catalog, so a nested author keeps the reference material they had. #713 + * installs the executable catalog; this is the index that goes with it. + */ + documentation: readonly DocumentationContribution[] = [], ): CatalogObservation { const rendered = renderSyntaxMarkdown(catalog); return { @@ -255,7 +275,7 @@ export function fixedCatalogObservation( return rendered; }, *document(names: readonly string[]): Operation { - const index = yield* documentationIndexFor(); + const index = yield* documentationIndexFor(documentation); return renderSelectedDocumentation(select(reference, catalog, names, index)); }, }; From 71dbe97db10d95b159b4648d4a1d2a96ef774644 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 05:46:12 -0400 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=93=96=20Document=20every=20first-p?= =?UTF-8?q?arty=20component,=20and=20gate=20the=20build=20on=20it=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five registration boundaries now each own a `components.md` beside themselves, with a contribution derived from the declarations that boundary registers rather than a list somebody maintains: - core's own components, and its Agent registrations; - the CLI's ``; - testing — ``, ``, the fourteen assertions, and the five execution-harness components; - web's ``; - all thirteen repository-composition components, `` included — the one registered from a definition rather than spelled inline, and so the easiest to miss if the set were hand-written. Each section is written against the component's actual declaration: its forms, its props, whether `as` is required, what activates it, and what it refuses. The last round shipped three examples that read plausibly and were wrong, so this round every example was checked against the registration it documents rather than against my memory of it. **A build cannot produce a distribution without the complete index.** `scripts/validate-documentation.ts` runs the same assembly the `run` profile does, and `deno task build` and `build-npm.ts` both pass through it. Copying the assets was never the check: a package built from a drifted set installs cleanly and refuses the first time somebody asks it for documentation. Each failure class is mutation-tested against real shipped files — a deleted section, an unknown heading, a duplicated section, and drift in a package outside core. Wiring the boundaries immediately found the next gap: four of the six assets were absent from the compiled `--include` list and the npm copy list, so the binary resolved `xmd syntax Git.Commit` to a missing-asset error. All six now ship through compiled, npm and JSR — the JSR dry run lists every one. Also adds the cancellation case: a named observation halted mid-flight completes its teardown and commits no successful `syntax_catalog` record. And the compiled probe now compares its output byte-for-byte with the source CLI for `Git.Commit`, a component outside core's own documentation file, so the comparison exercises a second copied asset path rather than re-proving the first. --- architecture.md | 15 +- deno.json | 3 +- packages/cli/src/components.md | 24 ++ packages/cli/src/syntax.ts | 19 +- packages/cli/src/verbose-component.ts | 13 +- packages/core/mod.ts | 6 +- packages/core/src/component-documentation.ts | 19 ++ packages/core/tests/syntax-component.test.ts | 34 +++ packages/testing/mod.ts | 6 +- packages/testing/src/components.md | 284 ++++++++++++++++++ packages/testing/src/components.ts | 17 ++ packages/web/mod.ts | 2 +- packages/web/src/components.md | 26 ++ packages/web/src/components.ts | 13 +- packages/workflow/mod.ts | 1 + .../workflow/src/composition/components.md | 190 ++++++++++++ .../workflow/src/composition/installation.ts | 27 +- scripts/build-npm.ts | 25 +- .../tests/documentation-validation.test.ts | 85 ++++++ scripts/tests/plan-component-compiled.test.ts | 37 +++ scripts/validate-documentation.ts | 44 +++ specs/executable-mdx-spec.md | 15 +- 22 files changed, 876 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/components.md create mode 100644 packages/testing/src/components.md create mode 100644 packages/web/src/components.md create mode 100644 packages/workflow/src/composition/components.md create mode 100644 scripts/tests/documentation-validation.test.ts create mode 100644 scripts/validate-documentation.ts diff --git a/architecture.md b/architecture.md index 8207f382..653e4047 100644 --- a/architecture.md +++ b/architecture.md @@ -3702,10 +3702,17 @@ evaluation — so a nested author can be told how a component works where they m not run one, without being left to infer that documentation implies authority. #713 installs that boundary; this stack supplies and proves the seam. -The documentation itself is the owning package's. A registration bundle keeps -`components.md` beside its own boundary, located from that module's URL rather -than the working directory or `--include`, and every distribution loads the same -bytes. One validated index serves ``, `xmd syntax Elicit` and +The documentation itself is the owning package's. Every registration boundary +that contributes public components keeps a `components.md` beside itself — core, +its Agent registrations, the CLI, testing, web and repository composition — with +a contribution derived from the same declarations it registers, so adding a +component demands documentation rather than relying on a hand-kept list. The +assembled contributions travel by value on the execution installation, which is +what makes the command and the component read one index. Assets are located from +their own module's URL rather than the working directory or `--include`, and +every distribution loads the same bytes; one shared entrypoint builds the +complete index and refuses a missing, unknown or duplicated section before any +distribution is produced. One validated index serves ``, `xmd syntax Elicit` and #678's release reference, so three surfaces cannot describe one component three ways. It joins by name *and* owning package: a repository `Elicit.md` has a repository origin, which names no package, so the built-in's prose is never diff --git a/deno.json b/deno.json index a1311041..923155a7 100644 --- a/deno.json +++ b/deno.json @@ -58,7 +58,8 @@ "verify:clean": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/verify-clean.ts", "deps": "deno run --allow-all scripts/deps.ts", "deps:target": "deno run --allow-all scripts/deps-target.ts", - "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --include packages/core/src/components/components.md --include packages/core/src/agent/components.md --output dist/xmd packages/cli/src/compiled.ts", + "validate:docs": "deno run --allow-all scripts/validate-documentation.ts", + "build": "deno task validate:docs && deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents --include packages/core/src/components/components.md --include packages/core/src/agent/components.md --include packages/cli/src/components.md --include packages/testing/src/components.md --include packages/web/src/components.md --include packages/workflow/src/composition/components.md --output dist/xmd packages/cli/src/compiled.ts", "build:web": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/build-web-client.ts", "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", diff --git a/packages/cli/src/components.md b/packages/cli/src/components.md new file mode 100644 index 00000000..8507c058 --- /dev/null +++ b/packages/cli/src/components.md @@ -0,0 +1,24 @@ +Long-form documentation for the components the `xmd` command registers. + +One component, and it exists because a document usually has two audiences: the +person running it, who wants the result, and the person debugging it, who wants +to know how it got there. + +## Verbose + +Expands its content only when run verbosity is on. + +```mdx + +Resolved {documents.length} documents from {include}. + +``` + +`--verbose` turns it on for the whole run, and a component may override +verbosity for its own content. When verbosity is off the content is not +expanded at all — so anything expensive inside it costs nothing on an ordinary +run, and this is a place to put detail rather than a place to hide it. + +`as` captures the rendered verbose text, or an **empty string** when verbosity +is off. That is what lets a document build a diagnostic once and use it in more +than one place without branching on the flag itself. diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 1eabc12c..6ab34664 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -30,10 +30,10 @@ import { selectDocumented, } from "@executablemd/core"; import type { DocumentationContribution, SyntaxCatalog } from "@executablemd/core"; -import { TESTING_REGISTRATIONS } from "@executablemd/testing"; -import { WEB_REGISTRATIONS } from "@executablemd/web"; -import { VERBOSE_REGISTRATION } from "./verbose-component.ts"; -import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; +import { TESTING_REGISTRATIONS, testingDocumentation } from "@executablemd/testing"; +import { WEB_REGISTRATIONS, webDocumentation } from "@executablemd/web"; +import { cliDocumentation, VERBOSE_REGISTRATION } from "./verbose-component.ts"; +import { COMPOSITION_REGISTRATIONS, compositionDocumentation } from "@executablemd/workflow"; export { renderSyntaxMarkdown }; @@ -107,7 +107,16 @@ export function* useRunProfileRegistry(): Operation { * does. */ export function* runProfileDocumentation(): Operation { - return [yield* agentDocumentation()]; + // One entry per boundary `useRunProfileRegistry()` installs, in the same + // order and from the same declarations. Core's own is added by + // `documentationIndexFor()`; these are the boundaries outside it. + return [ + yield* agentDocumentation(), + yield* cliDocumentation(), + yield* testingDocumentation(), + yield* webDocumentation(), + yield* compositionDocumentation(), + ]; } /** diff --git a/packages/cli/src/verbose-component.ts b/packages/cli/src/verbose-component.ts index c9d05659..2d52d22d 100644 --- a/packages/cli/src/verbose-component.ts +++ b/packages/cli/src/verbose-component.ts @@ -14,8 +14,8 @@ * observed. */ -import { content, verbose } from "@executablemd/core"; -import type { ComponentRegistration, Json } from "@executablemd/core"; +import { content, packageDocumentation, verbose } from "@executablemd/core"; +import type { ComponentRegistration, DocumentationContribution, Json } from "@executablemd/core"; import type { Operation } from "effection"; export const VERBOSE_ORIGIN = "@executablemd/cli"; @@ -33,6 +33,15 @@ function* Verbose(_props: Record): Operation { return yield* content(); } +/** This command's long-form documentation, derived from what it registers. */ +export function* cliDocumentation(): Operation { + return yield* packageDocumentation( + new URL("./components.md", import.meta.url), + { owner: VERBOSE_ORIGIN, asset: "packages/cli/src/components.md" }, + [VERBOSE_REGISTRATION.name], + ); +} + /** The one declaration the run profile registers and `xmd syntax` describes. */ export const VERBOSE_REGISTRATION: ComponentRegistration = { name: "Verbose", diff --git a/packages/core/mod.ts b/packages/core/mod.ts index c5362f1e..7f60ebfb 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -226,7 +226,11 @@ export type { SelectedEntry } from "./src/syntax-markdown.ts"; * lookup rather than two that agree by hand: the command reaches the index and * the selection core's own component reaches. */ -export { agentDocumentation, documentationIndexFor } from "./src/component-documentation.ts"; +export { + agentDocumentation, + documentationIndexFor, + packageDocumentation, +} from "./src/component-documentation.ts"; export type { DocumentationContribution } from "./src/component-documentation.ts"; export { select as selectDocumented } from "./src/syntax-observation.ts"; export { NO_DOCUMENTATION, UnknownComponentError } from "./src/documentation-index.ts"; diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index c0182bab..2a09862f 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -88,6 +88,25 @@ export function* readCoreDocumentation(): Operation { } } +/** + * A contribution built from the registrations it documents. + * + * The set is derived from the same declarations the package installs, so a + * component added to a boundary demands documentation without anyone having to + * remember to list it here. That is the whole point of deriving it: a + * hand-maintained second list is exactly the thing that goes stale. + */ +export function* packageDocumentation( + url: URL, + named: { owner: string; asset: string }, + supplies: Iterable, +): Operation { + return { + source: yield* readPackagedDocumentation(url, named), + supplies: new Set(supplies), + }; +} + /** One packaged documentation asset, read the same guarded way. */ export function* readPackagedDocumentation( url: URL, diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 478eeb19..d0bca4d2 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -363,6 +363,40 @@ describe("Tier SYN — the named form", () => { }); }); + it("SYN46: a cancelled named observation tears down and commits nothing", function* () { + const torn: string[] = []; + const stream = new InMemoryStream(); + + // A host whose catalog contribution suspends: the named form is inside the + // observation when the scope is cancelled, which is the window a record + // could be written in. + const suspending: ExecutionInstallation = { + *catalog(): Operation { + yield* ensure(() => { + torn.push("observation"); + }); + yield* suspend(); + return catalogOf("Unreachable"); + }, + }; + + yield* scoped(function* () { + const task = yield* spawn(function* () { + return yield* run('\n', [suspending], stream); + }); + // Let the observation get inside its operation before cancelling it. + yield* sleep(20); + yield* task.halt(); + }); + + // Teardown ran, so the observation's own cleanup completed rather than + // being abandoned mid-flight. + expect(torn).toEqual(["observation"]); + // And nothing successful was retained: a continuation has no catalog to + // restore, which is the honest state for work that never finished. + expect(retained(yield* stream.readAll())).toHaveLength(0); + }); + it("SYN39: retains the named text, and a continuation restores it whole", function* () { const stream = new InMemoryStream(); const first = String(yield* run('\n', [], stream)); diff --git a/packages/testing/mod.ts b/packages/testing/mod.ts index b858f720..e36976fc 100644 --- a/packages/testing/mod.ts +++ b/packages/testing/mod.ts @@ -60,7 +60,11 @@ export { Test, testing, record, results, TestFailureError } from "./src/test-api.ts"; export type { TestApi, TestResult, BoundaryOutcome } from "./src/test-api.ts"; -export { installTestingComponents, TESTING_REGISTRATIONS } from "./src/components.ts"; +export { + installTestingComponents, + TESTING_REGISTRATIONS, + testingDocumentation, +} from "./src/components.ts"; export { useTesting } from "./src/use-testing.ts"; // The nested-execution harness. This package owns the authored components and // the request-only host-profile surface; the trusted answer is attached to the diff --git a/packages/testing/src/components.md b/packages/testing/src/components.md new file mode 100644 index 00000000..dd2feb1f --- /dev/null +++ b/packages/testing/src/components.md @@ -0,0 +1,284 @@ +Long-form documentation for the components the testing package registers. + +Two components, and they work together: `` turns testing on for a +region, and `` states that a piece of work is *supposed* to fail. +The cases themselves are written with core's ``, which is inert until one +of these — or `xmd test` — asks for them. + +## Testing + +Turns on testing for its content. + +```mdx + + +… + + +``` + +Runs the `` elements inside and reports them. Two things fail the +document: a failing test, and finding **no tests at all** inside the region. +The second is deliberate — a testing region that silently matched nothing is +indistinguishable from one that passed, and a document whose tests stopped being +discovered should say so rather than go green. + +`` outside a testing region is skipped, so a document can carry its tests +and still be run as an ordinary document. + +## AssertThrows + +Asserts that its content fails. + +```mdx + + + +``` + +Passes only if the content fails *and* the failure message matches. `message` is +required and takes a string or a regular expression — required rather than +optional because an assertion that any failure will do passes for the wrong +reason as readily as the right one, and the message is what tells the two apart. + +Use it to pin a refusal you rely on: that a bad path fails, that an invalid +schema is rejected, that a guard actually guards. + +## Assert + +Asserts that a value is truthy. + +```mdx + +``` + +The base assertion. `msg` replaces the reported failure message, which is worth +writing whenever the expression alone would not tell a reader what went wrong. + +A passing assertion renders a diagnostic report only while testing or verbose +output is on, so an assertion inside an ordinary run is silent when it holds. A +failing one does not return. + +## AssertFalse + +Asserts that a value is falsy. + +```mdx + +``` + +The complement of ``. Prefer it over asserting `!value`, which reads as +a double negative at the point where a reader is trying to work out what should +be true. + +## AssertExists + +Asserts that a value is neither null nor undefined. + +```mdx + +``` + +Narrower than `` on purpose: `0`, `""` and `false` are all falsy and all +perfectly present, so an existence check written as a truthiness check fails on +legitimate values. + +## AssertEquals + +Asserts that two values are deeply equal. + +```mdx + +``` + +Deep equality, so objects and arrays compare by content rather than identity. +The expected value may also be written as content, which is easier to read when +it is large: + +```mdx + +{"name": "release", "steps": 3} + +``` + +## AssertNotEquals + +Asserts that two values are not deeply equal. + +```mdx + +``` + +Use it to pin that something actually changed — a step that is supposed to +rewrite a file, or a retry that should not return the first answer again. + +## AssertStrictEquals + +Asserts that two values are the same, compared with `===`. + +```mdx + +``` + +Identity rather than content. This is the assertion for "the same object", where +`` would pass for a copy. + +## AssertNotStrictEquals + +Asserts that two values are not the same, compared with `===`. + +```mdx + +``` + +The counterpart: pins that something was copied rather than shared, which is +what you want when a later step is going to mutate one of them. + +## AssertMatch + +Asserts that a string matches a pattern. + +```mdx + +``` + +For output whose exact text is not the contract — a message that carries a path, +a timestamp, or a count. Match the part that is the contract and leave the rest +free. + +## AssertNotMatch + +Asserts that a string does not match a pattern. + +```mdx + +``` + +Useful for the absence of something: that a rendering leaked no token, that a +diagnostic did not reach a user-facing surface. + +## AssertStringIncludes + +Asserts that a string contains a substring. + +```mdx + +``` + +The plainer form of `` when what you are looking for is literal +text rather than a shape. + +## AssertGreater + +Asserts that one number is greater than another. + +```mdx + +``` + +## AssertGreaterOrEqual + +Asserts that one number is greater than or equal to another. + +```mdx + +``` + +The inclusive form. Reach for it when the boundary value is acceptable — a +threshold that is met exactly is usually met. + +## AssertLess + +Asserts that one number is less than another. + +```mdx + +``` + +## AssertLessOrEqual + +Asserts that one number is less than or equal to another. + +```mdx + +``` + +## Execution + +Runs another document from inside a test, and asserts on how it finished. + +```mdx + + + +``` + +The child is real: its own journal, its own output, its own lifecycle. Pass +`source` instead of `target` to supply the Markdown directly, and `props` for +the child's properties. + +`as` binds the child's outcome — a settled result, or a suspension. Bind it: +**without `as`, a settled failure fails the owning test rather than passing +vacuously**, which is the safe direction but not usually what you meant to +write. The content is the child's declarations, then the assertions about it. + +## WorkflowRun + +Scopes a workflow-hosted execution. + +```mdx + + +… + + +``` + +A region that owns the workflow-hosted executions inside it, so a test that +drives a workflow has somewhere for that run's resources to belong and be torn +down. + +## DiagnosticJournal + +Gives a child execution a journal of its own. + +```mdx + + + + +``` + +Goes inside ``, before the assertions, and is **invalid anywhere +else**. Pair it with ``: this creates the journal, that reads +it. + +## CollectOutput + +Captures a child execution's output so a test can assert on it. + +```mdx + + + + +``` + +Goes inside ``, before the assertions, and is invalid anywhere else. +`as` is required. It changes nothing about the run, and a child that fails +partway still leaves what it printed — which is often exactly what the test +needs to see. + +## CollectJournal + +Captures a child execution's journal so a test can assert on it. + +```mdx + + + + +``` + +Placed like ``, and `as` is required. It reads a journal the run +already has — pair it with `` to create one. diff --git a/packages/testing/src/components.ts b/packages/testing/src/components.ts index 056ff628..ddfb1651 100644 --- a/packages/testing/src/components.ts +++ b/packages/testing/src/components.ts @@ -34,6 +34,7 @@ import type { Operation } from "effection"; import { Component, documented, + packageDocumentation, registerComponents, Execution, TestActivation, @@ -42,6 +43,7 @@ import { import type { ComponentFailure, ComponentRegistration, + DocumentationContribution, DocumentExecution, } from "@executablemd/core"; import { boundary, record, Test, testing, TestFailureError } from "./test-api.ts"; @@ -98,6 +100,21 @@ const TEST_TIMEOUT_MS = 20_000; * `` is deliberately absent: that construct is core's, and what this * package installs is what a test *does* (#441). */ +/** + * This package's long-form documentation, and the components it must cover. + * + * The set is derived from `TESTING_REGISTRATIONS` below, so adding a component + * to that array demands a section for it rather than quietly shipping one + * without. + */ +export function* testingDocumentation(): Operation { + return yield* packageDocumentation( + new URL("./components.md", import.meta.url), + { owner: TESTING_ORIGIN, asset: "packages/testing/src/components.md" }, + TESTING_REGISTRATIONS.map((registration) => registration.name), + ); +} + export const TESTING_REGISTRATIONS: readonly ComponentRegistration[] = [ // Non-reserved defaults: a repository component of any of these names is // chosen ahead of them, as it would be ahead of any other package's. diff --git a/packages/web/mod.ts b/packages/web/mod.ts index 6ef87cd6..4587ab03 100644 --- a/packages/web/mod.ts +++ b/packages/web/mod.ts @@ -14,7 +14,7 @@ * printed first and the form keeps waiting either way. */ -export { installWebComponents, WEB_REGISTRATIONS } from "./src/components.ts"; +export { installWebComponents, WEB_REGISTRATIONS, webDocumentation } from "./src/components.ts"; export { installWebElicitation } from "./src/elicitation.ts"; export { liveForm } from "./src/live-form.ts"; export type { LiveFormInput } from "./src/live-form.ts"; diff --git a/packages/web/src/components.md b/packages/web/src/components.md new file mode 100644 index 00000000..852e2335 --- /dev/null +++ b/packages/web/src/components.md @@ -0,0 +1,26 @@ +Long-form documentation for the components the web package registers. + +One component. It answers the same question core's `` does — ask a +person something and validate the answer — and differs only in *where* the +asking happens. + +## WebForm + +Asks a person a question in a browser form. + +```mdx + +Choose how the release notes should be grouped. + +``` + +Builds the form from `schema` and shows its content above it. `uiSchema` sets +presentation options — ordering, widgets, labels — without changing what the +answer has to be. The validated response binds through `as`, which is required: +the answer is the point. + +Reach for `` instead when the document should not choose the browser. It +asks the same question and lets the host decide how — a terminal prompt, or +whatever else that host arranges. `` is for when the question genuinely +needs a form: several fields, a choice among many, anything awkward to type at a +prompt. diff --git a/packages/web/src/components.ts b/packages/web/src/components.ts index c7c90e85..e51360af 100644 --- a/packages/web/src/components.ts +++ b/packages/web/src/components.ts @@ -11,14 +11,23 @@ * act — `installWebElicitation()` — and nothing about it is component metadata. */ -import { documented, registerComponents } from "@executablemd/core"; -import type { ComponentRegistration } from "@executablemd/core"; +import { documented, packageDocumentation, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration, DocumentationContribution } from "@executablemd/core"; import type { Operation } from "effection"; import { WEB_FORM_PROPS, WEB_FORM_RETURNS, WebForm } from "./WebForm.ts"; export const WEB_ORIGIN = "@executablemd/web"; +/** This package's long-form documentation, derived from its registrations. */ +export function* webDocumentation(): Operation { + return yield* packageDocumentation( + new URL("./components.md", import.meta.url), + { owner: WEB_ORIGIN, asset: "packages/web/src/components.md" }, + WEB_REGISTRATIONS.map((registration) => registration.name), + ); +} + export const WEB_REGISTRATIONS: readonly ComponentRegistration[] = [ { name: "WebForm", diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index af9fb96a..21a18f2d 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -213,6 +213,7 @@ export type { export { admitPushEvidence } from "./src/composition/push-evidence.ts"; export { COMPOSITION_REGISTRATIONS, + compositionDocumentation, useCompositionComponents, } from "./src/composition/installation.ts"; diff --git a/packages/workflow/src/composition/components.md b/packages/workflow/src/composition/components.md new file mode 100644 index 00000000..030999d7 --- /dev/null +++ b/packages/workflow/src/composition/components.md @@ -0,0 +1,190 @@ +Long-form documentation for the repository-composition components. + +Thirteen components for working with repositories, branches, pull requests and +issues from inside a document. They are mostly *regions*: ``, +`` and `` establish where the work happens, and the `Git.*`, +`PullRequest.*` and issue components act inside whatever those established. + +The pattern to hold onto is that a document says *what* it wants — this +repository, this branch, this commit — and the components decide whether that +means creating something or using what is already there. Running one twice does +not make two. + +## Repository + +Clones a repository, or uses the clone already there. + +```mdx + +… + +``` + +A region: its content runs with that repository as the working directory. +`name` identifies the checkout across runs, so a second run reuses the first's +rather than cloning again, and `url` says where it comes from. + +The checkout survives the run. Nothing here deletes, resets, cleans or repairs +one — a document that wants a clean tree asks for it explicitly. + +## Worktree + +Creates a linked checkout, or uses the one already there. + +```mdx + +… + +``` + +A region, expanding its content in the worktree. Use it to work on a branch +without disturbing the main checkout — two worktrees of one repository can be on +two branches at once, which is what makes a document that reviews one branch +while building another possible. + +`as` captures the path of the linked checkout, for a step that needs to name it. + +## Dir + +Changes the working directory for its content. + +```mdx + +… + +``` + +The plainest of the three regions: no repository, no branch, just a directory. +It pairs naturally with ``, which hands you a path this can +then work inside. + +## Git.Switch + +Switches to a branch, creating it if it does not exist. + +```mdx + +``` + +`base` says what a *new* branch starts from; it is ignored when the branch +already exists, so the same element is correct on the first run and on the +tenth. Self-closing, and it renders nothing. + +## Git.Add + +Stages paths for commit. + +```mdx + +``` + +`paths` is a list, so one element stages everything a step produced. Staging is +explicit rather than implied by `` because a document that commits +everything it happens to have touched is a document that commits surprises. + +## Git.Commit + +Commits what is staged. + +```mdx +Prepare 1.4 +``` + +The content is the commit message, so a message can be as long as it needs to be +and can interpolate what the document learned. Commits only what `` +staged. + +## Git.Push + +Publishes the current branch. + +```mdx + +``` + +Self-closing. Pushes the branch the working tree is on, to the remote the +repository was cloned from — so which branch is decided by `` above +it rather than repeated here. + +## PullRequest + +Opens a pull request, or updates the one already open. + +```mdx + +What changed, and why. + +``` + +The content is the body. Opening and updating are one element for the same +reason cloning and reusing are: a document that runs twice should end in the +state it describes, not with two pull requests. + +Push the branch first — `` — since there is nothing to open a pull +request against until the branch exists on the remote. + +## PullRequest.Reviews + +Reads the reviews on a pull request. + +```mdx + +``` + +`as` is required: this is a read, and the reviews are the result. Use it to let +a document act on what people said — hold a release until an approval lands, or +collect the changes a reviewer asked for. + +## PullRequest.Comments + +Reads the comments on a pull request. + +```mdx + +``` + +Comments rather than reviews: the discussion, including comments that carry no +verdict. `as` is required. + +## PullRequest.Checks + +Reads the check results on a pull request. + +```mdx + +``` + +`as` is required. This is what a document branches on when it should only +proceed once CI is green — read the checks, then decide, rather than merging and +hoping. + +## IssueTracker + +Selects the issue tracker its content works with. + +```mdx + +… + +``` + +A region, like `` above it: the issue components inside it resolve +against this tracker. A document that touches two trackers writes two regions +rather than repeating the URL on every element. + +## Issue + +Reads an issue, or files one. + +```mdx + + + +What failed, and what to try. + +``` + +The self-closing form with `url` reads an existing issue and binds it. The +paired form with `title` files a new one, with the content as the body — which +is how a document that finds something wrong can record it where the next person +will look. diff --git a/packages/workflow/src/composition/installation.ts b/packages/workflow/src/composition/installation.ts index ba4a6872..6e0b980d 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/workflow/src/composition/installation.ts @@ -21,8 +21,13 @@ */ import type { Operation } from "effection"; -import { documented, formDispatcher, registerComponents } from "@executablemd/core"; -import type { ComponentRegistration } from "@executablemd/core"; +import { + documented, + formDispatcher, + packageDocumentation, + registerComponents, +} from "@executablemd/core"; +import type { ComponentRegistration, DocumentationContribution } from "@executablemd/core"; import { COMPOSITION_ORIGIN, dirDefinition } from "./definitions.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; @@ -54,6 +59,24 @@ import IssueTracker, { props as issueTrackerProps } from "./components/IssueTrac const dir = dirDefinition(); /** The one vocabulary every consumer of these components describes. */ +/** + * This boundary's long-form documentation, and the components it must cover. + * + * Derived from `COMPOSITION_REGISTRATIONS` below — including ``, which is + * registered from a definition rather than spelled inline, and would be the + * easiest one to leave undocumented if this list were maintained by hand. + */ +export function* compositionDocumentation(): Operation { + return yield* packageDocumentation( + new URL("./components.md", import.meta.url), + { + owner: COMPOSITION_ORIGIN, + asset: "packages/workflow/src/composition/components.md", + }, + COMPOSITION_REGISTRATIONS.map((registration) => registration.name), + ); +} + export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ { name: "Repository", diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index b5064d54..a018abe2 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -22,6 +22,7 @@ */ import { ensure, exit, main, scoped, until } from "effection"; +import { validateDocumentation } from "./validate-documentation.ts"; import type { Operation } from "effection"; import { build } from "jsr:@deno/dnt@0.42.3"; import { @@ -54,13 +55,15 @@ function* packagedDocuments(pkgDir: URL): Operation { // Component documentation lives beside the registration boundary it // documents rather than in `src/documents/`, because that is where the // components are and moving it would separate the two things that have to - // stay in step. Named by its exact path for the same reason the directory - // above is enumerated rather than swept for: being listed here is what - // declares an asset part of the product. - // One entry per registration boundary that documents its components. Named - // rather than swept for, because `src/` also holds test documents and - // scenario fixtures: being listed here is what declares an asset shipped. - for (const relative of ["src/components/components.md", "src/agent/components.md"]) { + // stay in step. One entry per boundary, named rather than swept for: `src/` + // also holds test documents and scenario fixtures, so being listed here is + // what declares an asset shipped. + for (const relative of [ + "src/components/components.md", + "src/agent/components.md", + "src/components.md", + "src/composition/components.md", + ]) { if (yield* exists(new URL(relative, pkgDir))) { shipped.push(relative); } @@ -128,6 +131,14 @@ await main(function* (args) { return; } + // Before anything is emitted. Copying the documentation assets is not the + // same as validating them: a package built from a set that has drifted from + // the components it documents would install cleanly and refuse the first time + // somebody asked it for documentation. The same assembly the run profile uses + // runs here, so a missing, unknown or duplicated section fails the build for + // exactly the reason it would fail a run. + yield* validateDocumentation(); + const repoRoot = new URL("../", import.meta.url); const rootDeno = RootDenoSchema.parse( diff --git a/scripts/tests/documentation-validation.test.ts b/scripts/tests/documentation-validation.test.ts new file mode 100644 index 00000000..4a411d14 --- /dev/null +++ b/scripts/tests/documentation-validation.test.ts @@ -0,0 +1,85 @@ +/** + * Tier SYN — the build gate for first-party documentation. + * + * `scripts/validate-documentation.ts` is what stands between a drifted + * documentation set and a published distribution. A gate nobody has watched + * fail is a gate nobody knows is connected, so each case here plants one class + * of drift in a real shipped asset, runs the real entrypoint, and puts the file + * back. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure } from "effection"; +import type { Operation } from "effection"; +import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { exec } from "@effectionx/process"; + +/** A shipped asset, restored however the case ends. */ +function* planted(relative: string, change: (text: string) => string): Operation { + const url = new URL(`../../${relative}`, import.meta.url); + const original = yield* readTextFile(url); + yield* ensure(() => writeTextFile(url, original)); + yield* writeTextFile(url, change(original)); +} + +/** The real gate, as the build runs it. */ +function* validate(): Operation<{ ok: boolean; stderr: string }> { + const run = yield* exec("deno", { + arguments: ["run", "--allow-all", "scripts/validate-documentation.ts"], + }).join(); + return { ok: run.code === 0, stderr: run.stderr }; +} + +const CORE = "packages/core/src/components/components.md"; +const COMPOSITION = "packages/workflow/src/composition/components.md"; + +describe("Tier SYN — the documentation build gate", () => { + it("SYN41: passes on the shipped set", function* () { + const clean = yield* validate(); + expect(clean.ok).toBe(true); + expect(clean.stderr).toContain("complete"); + }); + + it("SYN42: refuses a deleted section", function* () { + // `## Fetch` and its body, gone — the drift that happens when a component + // is documented and the section is later lost to a bad merge. + yield* planted(CORE, (text) => { + const start = text.indexOf("## Fetch"); + const end = text.indexOf("## Glob"); + return text.slice(0, start) + text.slice(end); + }); + const refused = yield* validate(); + expect(refused.ok).toBe(false); + expect(refused.stderr).toContain("Fetch"); + expect(refused.stderr).toContain("no documentation"); + }); + + it("SYN43: refuses an unknown section", function* () { + // Documentation for something the package does not supply — a rename that + // updated the code and not the file. + yield* planted(CORE, (text) => `${text}\n## Nonexistent\n\nAbout nothing.\n`); + const refused = yield* validate(); + expect(refused.ok).toBe(false); + expect(refused.stderr).toContain("Nonexistent"); + }); + + it("SYN44: refuses a duplicated section", function* () { + yield* planted(CORE, (text) => `${text}\n## Fetch\n\nA second Fetch.\n`); + const refused = yield* validate(); + expect(refused.ok).toBe(false); + expect(refused.stderr).toContain("Fetch"); + }); + + it("SYN45: refuses drift in a package outside core", function* () { + // The same gate covers every boundary, not only the first one wired. + yield* planted(COMPOSITION, (text) => { + const start = text.indexOf("## Git.Push"); + const end = text.indexOf("## PullRequest\n"); + return text.slice(0, start) + text.slice(end); + }); + const refused = yield* validate(); + expect(refused.ok).toBe(false); + expect(refused.stderr).toContain("Git.Push"); + }); +}); diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 8b639e59..5f36d379 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -26,6 +26,7 @@ import { exec } from "@effectionx/process"; import { timebox } from "@effectionx/timebox"; import type { ProcessResult } from "@effectionx/process"; import { createHash } from "node:crypto"; +import { fileURLToPath as fromFileUrl } from "node:url"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -134,6 +135,42 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => expect(lookup.value.stdout).toContain("Asks a person a structured question"); expect(lookup.value.stdout).toContain("**Available in this evaluation:** yes"); + // A component from a boundary *outside* core's own documentation file, so + // the probe exercises a second copied asset path rather than proving only + // that the first one shipped. + const outside = yield* timebox(TIMEOUT, function* () { + return yield* exec(BINARY, { + arguments: ["syntax", "Git.Commit", "--include", elsewhere], + cwd: elsewhere, + }).join(); + }); + if (outside.timeout) { + throw new Error("the compiled binary timed out documenting a composition component"); + } + expect(outside.value.code).toBe(0); + expect(outside.value.stdout).toContain("### ``"); + expect(outside.value.stdout).toContain("Commits what is staged"); + + // And the compiled answer is the source answer, byte for byte. + const fromSource = yield* timebox(TIMEOUT, function* () { + return yield* exec("deno", { + arguments: [ + "run", + "--allow-all", + fromFileUrl(new URL("../../packages/cli/src/deno.ts", import.meta.url)), + "syntax", + "Git.Commit", + "--include", + elsewhere, + ], + cwd: elsewhere, + }).join(); + }); + if (fromSource.timeout) { + throw new Error("the source CLI timed out documenting a composition component"); + } + expect(fromSource.value.stdout).toBe(outside.value.stdout); + // The command surface those bytes belong to is source-only in this build // too: help describes both explicit compositions and names no option that // would run the approved program. diff --git a/scripts/validate-documentation.ts b/scripts/validate-documentation.ts new file mode 100644 index 00000000..fee30436 --- /dev/null +++ b/scripts/validate-documentation.ts @@ -0,0 +1,44 @@ +/** + * Build the complete first-party documentation index, or refuse. + * + * The one entrypoint every distribution passes through before it is produced. + * Copying the Markdown assets is not the same as validating them: a build that + * only copied would happily ship a package whose documentation had drifted from + * the components it documents, and the first person to notice would be an + * author whose `` refused at run time. + * + * So the check is the real assembly. It reads the same contributions the `run` + * profile installs, through the same loader, and builds the same index — which + * means a missing section, an unknown heading and a component documented twice + * each fail the build for exactly the reason they would fail a run. + */ + +import { main } from "effection"; +import type { Operation } from "effection"; +import { documentationIndexFor } from "@executablemd/core"; +import { runProfileDocumentation } from "../packages/cli/src/syntax.ts"; + +/** Assemble the complete index, throwing whatever it refuses with. */ +export function* validateDocumentation(): Operation { + const index = yield* documentationIndexFor(yield* runProfileDocumentation()); + // Read one entry back, so a build cannot pass by assembling an index that + // holds nothing: an empty set satisfies every rule above vacuously. + const sample = index.documentationFor("Syntax", { + kind: "protected", + origin: "@executablemd/core", + }); + if (sample === undefined || sample.length === 0) { + throw new Error( + "the documentation index built without 's own documentation, so it is not the " + + "index this product ships", + ); + } + return 1; +} + +if (import.meta.main) { + await main(function* () { + const boundaries = yield* validateDocumentation(); + console.error(`component documentation: complete across ${boundaries} profile`); + }); +} diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 86bcaf1a..ee2a651a 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2712,9 +2712,15 @@ Documentation joins to metadata by component name **and owning package**. Only a registration and a protected component come from a package; a repository file, a bundled blob and declared Markdown are this run's, so a repository `Elicit.md` receives none of the built-in `Elicit`'s prose. **A first-party package documents every component it supplies.** The index is -built from one `components.md` per registration boundary, contributed at the -trusted installation boundary, and a boundary that supplies a component with no -section refuses the whole index — as does an unknown heading, a duplicate, and a +built from one `components.md` per registration boundary — core's own, its Agent +registrations, the CLI, testing, web and the repository-composition set — each +contributed at the trusted installation boundary from the same declarations that +boundary registers, so a component added to a package demands documentation +without anyone maintaining a second list. Contributions travel by value on the +execution installation, which is what makes `xmd syntax NAME` and a document's +own `` read one index rather than two. + +A boundary that supplies a component with no section refuses the whole index — as does an unknown heading, a duplicate, and a component documented twice for one package. A partially documented first-party package is not a valid build: a reader cannot tell an undocumented component from one that has nothing to say, and the product's own reference would be @@ -10907,6 +10913,9 @@ component that observes one at an authored site. | SYN35–SYN37 | Building the index | A heading naming something the package does not supply refuses; one component documented twice refuses; documentation attaches by name and owning package, never to a repository replacement | | SYN38 | Exact coverage | A package supplying a component it does not document refuses the whole index — deleting any one built-in's section fails — with a fully covered package as the positive control | | SYN40 | The protection boundary | A repository component wrapping the named form with planted `API.Fs` middleware cannot change what the documentation says, and the read never reaches that Api | +| SYN41–SYN45 | The build gate | `scripts/validate-documentation.ts` assembles the complete first-party index before any distribution is produced: the shipped set passes, and a deleted section, an unknown heading, a duplicated section, and drift in a package outside core each fail it | +| SYN46 | Cancelling a named observation | Teardown completes and no successful `syntax_catalog` record is committed | +| SX17 | One index, two surfaces | `xmd syntax NAME` and `` return the same text for a component outside core's own file | ### Tier SX — The `xmd syntax` command From 2e1ba288085d4cb3451dfd325be8a718d7ff4d89 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 06:01:58 -0400 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=AA=9F=20Derive=20a=20narrowed=20ob?= =?UTF-8?q?servation=20from=20the=20enclosing=20one=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrowing seam required an evaluator to hold the raw documentation contributions and hand them back to build a narrowed observation. That list is execution-private for a reason, and rebuilding an index from it is how two indexes drift apart. `CatalogObservation` now derives its own: observation.narrow(admitted) What comes back reports the admitted vocabulary from `observe()` and keeps *this* observation's authoring catalog and documentation index for `document()`. #713 needs the admitted catalog and nothing else. Proved by breaking it: a narrowed observation that drops the enclosing reference fails SYN25e and nothing else. Contributions are snapshotted field by field at the boundary — owner, asset and text copied, the name set materialized into one this module owns. A contribution is a caller's object: its array can be reordered, its source replaced, its `Set` added to after capture, and an iterable can answer differently the second time it is walked. SYN25f mutates all of those after capture and proves the observed documentation and coverage are the captured values. SYN46 now proves cancellation *reached* the named documentation work rather than arriving before it: the observation records entry, then teardown, in that order, and a cancellation that never got inside would leave the first marker absent. It also states the mechanism correctly — a durable operation records its event on completion, so a cancelled one commits nothing at all rather than committing a failure. SX17 compares the complete rendered output of `xmd syntax Prompt` and `` rather than phrases from it. Substring agreement would pass just as happily if one surface kept the heading and silently dropped the documentation. Prose reconciled with what the code enforces: a first-party component with no section refuses the index, and the no-documentation sentence is for a custom component. `documentation-index.ts` and `architecture.md` said the opposite. --- architecture.md | 11 +- packages/cli/tests/syntax-cli.test.ts | 20 ++- packages/core/src/documentation-index.ts | 13 +- packages/core/src/syntax-observation.ts | 103 +++++++++++++-- packages/core/tests/syntax-component.test.ts | 127 +++++++++++++++++-- 5 files changed, 237 insertions(+), 37 deletions(-) diff --git a/architecture.md b/architecture.md index 653e4047..c330ab07 100644 --- a/architecture.md +++ b/architecture.md @@ -3716,10 +3716,13 @@ distribution is produced. One validated index serves ``, `xm #678's release reference, so three surfaces cannot describe one component three ways. It joins by name *and* owning package: a repository `Elicit.md` has a repository origin, which names no package, so the built-in's prose is never -attached to it. A heading naming something the package does not supply, one -appearing twice, and one that is not a component name each refuse the whole -index rather than producing a partial one; a component with no section is -ordinary and renders the sentence saying so. +attached to it. A first-party component with no +section, a heading naming something the package does not supply, one appearing +twice, and one that is not a component name each refuse the whole index rather +than producing a partial one — a reference with a hole in it cannot be told from +one whose components have nothing to say. The no-documentation sentence is for a +**custom** component instead, which no package governs and which takes its prose +from its own document's body when it has any. **It says so in the catalog.** A protected component reports its own origin kind, `protected`, rather than borrowing `registered` with `reserved: true`. The diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index b9435d5a..9de97c1b 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -500,17 +500,23 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources // profile's contributions while a document's own named form fell back to a // core-only index, so one product answered the same question two ways. const command = yield* runCli(["syntax", "Prompt", "--include", "."], { cwd: "." }).expect(); - expect(command.stdout).toContain("### ``"); - expect(command.stdout).toContain("Sends a prompt and renders the reply"); - const document = yield* runCli( ["run", "-e", '', "--include", "."], { cwd: "." }, ).expect(); - expect(document.stdout).toContain("Sends a prompt and renders the reply"); - expect(document.stdout).not.toContain( - "No long-form documentation is available for this component.", - ); + + // The whole rendered result, not a phrase from it. Both surfaces render the + // same text through the same renderer; they differ only in the trailing + // newline a rendered document ends with, which is the presentation boundary + // rather than the answer. Comparing substrings would pass just as happily + // if one surface silently dropped the documentation and kept the heading. + expect(document.stdout.trimEnd()).toBe(command.stdout.trimEnd()); + + // And it is a real answer rather than two matching empties. + expect(command.stdout).toContain("### ``"); + expect(command.stdout).toContain("Sends a prompt and renders the reply"); + expect(command.stdout).toContain("**Available in this evaluation:** yes"); + expect(command.stdout.length).toBeGreaterThan(400); }); it("SX10: writes markdown by default and version-2 JSON with --json", function* () { diff --git a/packages/core/src/documentation-index.ts b/packages/core/src/documentation-index.ts index 0b9e9513..a217ab48 100644 --- a/packages/core/src/documentation-index.ts +++ b/packages/core/src/documentation-index.ts @@ -23,8 +23,12 @@ * component whose section they are in, so a component's own documentation can * have structure without ending its section. * - * Three things refuse the whole index rather than producing a partial one: + * Four things refuse the whole index rather than producing a partial one: * + * - a **missing** section — a component the package supplies and does not + * document. A reader cannot tell "nobody has written this yet" from "this + * component has nothing to say", so a first-party package with a hole in its + * reference is not a valid build; * - a **duplicate** heading, in one file or across two, because then a component * has two documentations and nothing says which is current; * - an **unknown** heading, because it is documentation for something this @@ -32,9 +36,10 @@ * - a heading that is **not a component name at all**, which is a file that has * drifted from this format into ordinary prose. * - * A component with *no* section is not a failure. It renders the sentence - * `` states for one, and stays usable while its documentation is still - * being written. + * The no-documentation sentence `` renders is therefore *not* for a + * first-party component. It is for a **custom** component — a repository file, a + * bundle member, a host's declared Markdown — which no package governs and which + * takes its documentation from its own document's body when it has any. * * ## The join is name *and* origin * diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 70e0c34a..8c43e379 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -70,6 +70,22 @@ export interface CatalogObservation { * author needs or imply an authority they do not have. */ document(names: readonly string[]): Operation; + /** + * The observation for a subtree that may execute less than this site. + * + * The narrowing seam, and it belongs here rather than in the evaluator + * because everything it needs is already here. A canonical evaluation + * boundary that has admitted a vocabulary hands it over; what comes back + * reports that vocabulary from `observe()` and keeps *this* observation's + * authoring catalog and documentation index for `document()`. + * + * Deriving it any other way would mean the evaluator recovering the raw + * contributions and rebuilding an index — which is both a hole (that list is + * execution-private for a reason) and a way for the two indexes to drift. + * Narrowing what may run is not narrowing what may be read about, and the + * observation is the thing that already knows both. + */ + narrow(executable: SyntaxCatalog): CatalogObservation; } /** @@ -121,17 +137,48 @@ export function rootCatalogObservation( function* current(): Operation { return contribution === undefined ? yield* derived(inputs) : yield* contribution(); } + // Snapshotted once, here, so the contributions an observation reads are the + // ones the installation boundary captured rather than whatever the caller's + // objects hold by the time a document asks. + const captured = snapshotContributions(documentation); + return observing(current, current, captured); +} + +/** + * One observation over an authoring catalog and an executable one. + * + * `reference` is what named lookup reads and `executable` is what may run. At a + * root they are the same operation; a narrowed observation keeps the reference + * and replaces the executable, which is the whole of the seam. + */ +function observing( + reference: () => Operation, + executable: () => Operation, + documentation: readonly DocumentationContribution[], +): CatalogObservation { return { *observe(): Operation { - return renderSyntaxMarkdown(yield* current()); + return renderSyntaxMarkdown(yield* executable()); }, *document(names: readonly string[]): Operation { - // At the root the two inputs are one catalog: nothing has narrowed what - // may execute, so what an author may read about and what they may run are - // the same set, and every selected entry is available. - const catalog = yield* current(); + const authoring = yield* reference(); + const runnable = yield* executable(); const index = yield* documentationIndexFor(documentation); - return renderSelectedDocumentation(select(catalog, catalog, names, index)); + return renderSelectedDocumentation(select(authoring, runnable, names, index)); + }, + narrow(admitted: SyntaxCatalog): CatalogObservation { + // The enclosing reference and the enclosing index, unchanged. Only what + // may execute is replaced, so a nested author keeps the documentation + // they had and every entry reports its availability against the + // admission. + // deno-lint-ignore require-yield + return observing( + reference, + function* () { + return admitted; + }, + documentation, + ); }, }; } @@ -241,6 +288,33 @@ function* derived(inputs: CapturedCatalogInputs): Operation { * adds nothing: the catalog handed here is the admission's, so an entry that is * not in the admission cannot be in the observation. */ +/** + * A defensive copy of what a caller handed the installation boundary. + * + * Field by field, and the name set materialized into one this module owns. A + * contribution is a caller's object: the array can be reordered, the source + * replaced, the `Set` added to after capture, and an iterable can answer + * differently the second time it is walked. Retaining any of those would make + * what a document is told about the product depend on what its host did + * afterwards. + */ +export function snapshotContributions( + contributions: readonly DocumentationContribution[], +): readonly DocumentationContribution[] { + return Object.freeze( + [...contributions].map((one) => + Object.freeze({ + source: Object.freeze({ + owner: String(one.source.owner), + asset: String(one.source.asset), + text: String(one.source.text), + }), + supplies: new Set([...one.supplies].map((name) => String(name))), + }), + ), + ); +} + export function fixedCatalogObservation( catalog: SyntaxCatalog, /** @@ -268,15 +342,16 @@ export function fixedCatalogObservation( */ documentation: readonly DocumentationContribution[] = [], ): CatalogObservation { - const rendered = renderSyntaxMarkdown(catalog); - return { + const captured = snapshotContributions(documentation); + return observing( // deno-lint-ignore require-yield - *observe(): Operation { - return rendered; + function* () { + return reference; }, - *document(names: readonly string[]): Operation { - const index = yield* documentationIndexFor(documentation); - return renderSelectedDocumentation(select(reference, catalog, names, index)); + // deno-lint-ignore require-yield + function* () { + return catalog; }, - }; + captured, + ); } diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index d0bca4d2..0dc3d2b9 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -49,7 +49,9 @@ import { selectComponent } from "../src/components/select.ts"; import { installedBundle } from "../src/components/bundle.ts"; import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; -import { fixedCatalogObservation } from "../src/syntax-observation.ts"; +import { fixedCatalogObservation, rootCatalogObservation } from "../src/syntax-observation.ts"; +import type { CatalogObservation } from "../src/syntax-observation.ts"; +import type { DocumentationContribution } from "../src/component-documentation.ts"; import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; import type { ComponentOrigin, FunctionComponent, SyntaxCatalog } from "../mod.ts"; @@ -187,6 +189,48 @@ function* tampered( return partial; } +/** A catalog holding one component entry of exactly this identity. */ +function catalogNamed(name: string, origin: NamedOrigin): SyntaxCatalog { + return { + version: 2, + categories: [ + { kind: "structural", entries: [] }, + { + kind: "built-in", + entries: [ + { + kind: "component" as const, + name, + origin, + sourceKind: "registered" as const, + inspectability: "complete" as const, + forms: ["self-closing" as const], + props: { type: "object", properties: {}, additionalProperties: false }, + captures: [], + returnMode: "text" as const, + returns: { type: "string" }, + }, + ], + }, + { kind: "user-provided", entries: [] }, + ], + }; +} + +/** + * The observation an ordinary root carries. + * + * Built the way an execution builds it — from captured selection inputs, with + * no host contribution — so a case about narrowing is about the object the + * product actually hands to expansion. + */ +function rootObservation(): CatalogObservation { + return rootCatalogObservation( + { includes: [], registry: new Map(), components: [], declarations: [] }, + undefined, + ); +} + /** A working directory of this case's own, torn down on the way out. */ function useWorkingDirectory(body: (dir: string) => Operation): Operation { return scoped(function* () { @@ -372,8 +416,13 @@ describe("Tier SYN — the named form", () => { // could be written in. const suspending: ExecutionInstallation = { *catalog(): Operation { + // Entered *inside* the named documentation operation: the component has + // claimed its occurrence and opened its durable operation by the time + // this runs, so a cancellation that arrives now is one that landed in + // the work rather than before it. + torn.push("entered"); yield* ensure(() => { - torn.push("observation"); + torn.push("torn down"); }); yield* suspend(); return catalogOf("Unreachable"); @@ -389,12 +438,16 @@ describe("Tier SYN — the named form", () => { yield* task.halt(); }); - // Teardown ran, so the observation's own cleanup completed rather than - // being abandoned mid-flight. - expect(torn).toEqual(["observation"]); - // And nothing successful was retained: a continuation has no catalog to - // restore, which is the honest state for work that never finished. - expect(retained(yield* stream.readAll())).toHaveLength(0); + const events = yield* stream.readAll(); + // Reached the work, then tore it down — in that order. Cancelling before + // the observation was entered would leave `entered` absent, which is the + // vacuous pass this ordering rules out. + expect(torn).toEqual(["entered", "torn down"]); + // Nothing was committed at all: a durable operation records its event when + // it completes, and this one never did. So there is no record for a + // continuation to restore, successful or otherwise. + expect(observations(events)).toHaveLength(0); + expect(retained(events)).toHaveLength(0); }); it("SYN39: retains the named text, and a continuation restores it whole", function* () { @@ -1214,6 +1267,64 @@ describe("Tier SYN — observation is never authority", () => { ).toContain("**Available in this evaluation:** yes"); }); + it("SYN25e: a narrowed observation is derived from the enclosing one", function* () { + // The seam as an evaluator actually meets it: it holds the enclosing + // observation and an admitted catalog, and nothing else. No raw + // contribution list, no second index — which is the point, because that + // list is execution-private and rebuilding an index from it is how two + // indexes drift apart. + const enclosing = rootObservation(); + const admitted = catalogOf("Admitted"); + const narrowed = enclosing.narrow(admitted); + + // What may run is the admission. + const executable = yield* narrowed.observe(); + expect(executable).toContain("### ``"); + expect(executable).not.toContain("### ``"); + + // What may be read about is still the enclosing site's, with the enclosing + // index behind it — so a real component's real documentation survives. + const documented = yield* narrowed.document(["Elicit"]); + expect(documented).toContain("### ``"); + expect(documented).toContain("Asks a person a structured question"); + expect(documented).toContain("**Available in this evaluation:** no"); + + // And the enclosing observation is unchanged by having been narrowed. + expect(yield* enclosing.observe()).toContain("### ``"); + expect(yield* enclosing.document(["Elicit"])).toContain( + "**Available in this evaluation:** yes", + ); + }); + + it("SYN25f: contributions are captured, not held by reference", function* () { + const supplies = new Set(["Alpha"]); + const source = { + owner: "@executablemd/mutable", + asset: "packages/mutable/src/components.md", + text: "## Alpha\n\nThe captured documentation.\n", + }; + const contribution = { source, supplies }; + const observation = fixedCatalogObservation( + catalogNamed("Alpha", { + kind: "registered", + origin: "@executablemd/mutable", + reserved: false, + }), + undefined, + [contribution], + ); + + // Everything a caller still holds, changed after capture. + source.text = "## Alpha\n\nSUBSTITUTED AFTER CAPTURE.\n"; + source.owner = "@executablemd/other"; + supplies.add("Beta"); + supplies.delete("Alpha"); + + const rendered = yield* observation.document(["Alpha"]); + expect(rendered).toContain("The captured documentation."); + expect(rendered).not.toContain("SUBSTITUTED AFTER CAPTURE"); + }); + it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () { // `execute()` driven directly still carries one, so the case that has none // is an expansion driven outside an execution — which is what a component From a41a5990f02715913cf091c4637eb6f9661c9808 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 06:28:28 -0400 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=94=92=20Snapshot=20documentation?= =?UTF-8?q?=20at=20the=20installation=20boundary,=20and=20close=20the=20di?= =?UTF-8?q?stributions=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The snapshot was taken too late.** It happened inside `rootCatalogObservation()`, which runs long after `install()`. So an installation could hand over a contribution, then rewrite its own source text and name set from inside its own `install()` operation, and a document would be told whatever it changed them to. The capture moves to `runInvocation()`, before any `install()` runs, and only the captured value travels onward. SYN25g proves it end to end through `executeInstalled()`: an installation that mutates its source, owner and `Set` from `install()` gets the pre-installation snapshot back. It fails against 2e1ba288. **Cancellation now reaches the documentation work.** SYN46 records entry into the observation and its teardown, in that order — a cancellation arriving before the work would leave the first marker absent — and states the mechanism correctly: a durable operation records on completion, so a cancelled one commits nothing rather than committing a failure. **The npm boundary was not what I said it was.** I reported it as an environment fault after a `spawn sh ENOENT`. Reproduced directly with the harness's own environment, the build succeeds: exit 0, `npm install` clean, every package built. The earlier failure was transient and my attribution to a stale PATH was wrong — `/bin/sh` resolves here and always did. The probe now also runs the emitted binary's named lookup for `Git.Commit`, a component outside core's own documentation file, and compares it byte-for-byte with the source CLI. **A JSR consumer actually runs.** SYN47 stages core and its siblings outside the workspace, writes a consumer with an import map of its own that names no path in this repository, and asks it for two components' documentation. Listing the asset in a dry run proves it is in the payload; only this proves a consumer can load it. Verified discriminating: resolving the asset from the process working directory instead of the module URL fails it. --- packages/core/src/execute.ts | 10 +- packages/core/tests/syntax-component.test.ts | 38 +++++ scripts/tests/cli-npm-bin.test.ts | 19 +++ .../tests/jsr-consumer-documentation.test.ts | 149 ++++++++++++++++++ 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 scripts/tests/jsr-consumer-documentation.test.ts diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 892ef12a..4ae7ba0c 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -140,7 +140,7 @@ import type { IdentityComponent } from "./invocation-identity.ts"; import { ExecutionImports } from "./components/import-authority.ts"; import type { ExpansionAuthority, ImportTier } from "./components/import-authority.ts"; import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; -import { rootCatalogObservation } from "./syntax-observation.ts"; +import { rootCatalogObservation, snapshotContributions } from "./syntax-observation.ts"; import type { DocumentationContribution } from "./component-documentation.ts"; import type { CatalogContribution } from "./syntax-observation.ts"; import type { WorkflowComponentBundle, WorkflowImportAuthority } from "./components/bundle.ts"; @@ -3059,7 +3059,13 @@ function* invoke( // makes `` and `xmd syntax NAME` read one index — the // component reached a core-only index before this, so an Agent component had // documentation on the command line and the fallback sentence in a document. - const documentation = Object.freeze( + // + // Snapshotted here, field by field, and *before* any `install()` runs below. + // A shallow copy of the array would still hold the caller's source objects + // and name sets, so an installation could rewrite its own documentation from + // inside its `install()` — after the boundary that is supposed to have fixed + // it — and a document would be told whatever it changed them to. + const documentation = snapshotContributions( installations.flatMap((installation) => [...(installation.documentation ?? [])]), ); if (catalogs.length > 1) { diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 0dc3d2b9..c1dcb886 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -450,6 +450,44 @@ describe("Tier SYN — the named form", () => { expect(retained(events)).toHaveLength(0); }); + it("SYN25g: an installation cannot rewrite its documentation from install()", function* () { + // Everything a host still holds after handing its contribution over: the + // source object, its text, and the name set. `install()` runs *after* the + // capture boundary, which is exactly the window this closes — a snapshot + // taken later, or a shallow copy of the array, would serve whatever these + // say by the time a document asks. + // A package of its own, so this is about capture rather than about + // colliding with core's real documentation of the same name. + const supplies = new Set(["Marker"]); + const source = { + owner: "@executablemd/test", + asset: "packages/test/src/components.md", + text: "## Marker\n\nTHE CAPTURED PROSE.\n", + }; + + const installation: ExecutionInstallation = { + components: [], + documentation: [{ source, supplies }], + // deno-lint-ignore require-yield + *install(): Operation { + source.text = "## Marker\n\nSUBSTITUTED FROM INSTALL.\n"; + source.owner = "@executablemd/impostor"; + supplies.add("Substituted"); + supplies.delete("Marker"); + }, + }; + + const { installation: marker } = stating(catalogOf("Marker")); + const rendered = String(yield* run('\n', [marker, installation])); + + // The prose captured before `install()` ran, and none of what it wrote. + expect(rendered).toContain("THE CAPTURED PROSE."); + expect(rendered).not.toContain("SUBSTITUTED FROM INSTALL"); + // And the coverage it was captured with: adding a name afterwards neither + // demands documentation for it nor refuses the index. + expect(rendered).not.toContain("Substituted"); + }); + it("SYN39: retains the named text, and a continuation restores it whole", function* () { const stream = new InMemoryStream(); const first = String(yield* run('\n', [], stream)); diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index e921d880..1e554684 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -244,6 +244,25 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () 'catalog; `` renders selected documentation.', ); + // The documentation assets travel with the package, and the emitted binary + // resolves them from its own tree rather than from a checkout. `Prompt` + // lives in core's *agent* boundary rather than in its own `components.md`, + // so this exercises a second copied asset path: a build that copied only + // the first would still answer for core's own components and fail here. + const documented = yield* runEmittedBinIn(elsewhere, ["syntax", "Prompt"]); + expect(documented.code).toBe(0); + expect(documented.stdout).toContain("### ``"); + expect(documented.stdout).toContain("Sends a prompt and renders the reply"); + expect(documented.stdout).toContain("**Available in this evaluation:** yes"); + + // And it is the same answer the source tree gives, whole. + const fromSource = yield* exec(Deno.execPath(), { + arguments: ["run", "-A", path.join(ROOT, "packages/cli/src/deno.ts"), "syntax", "Prompt"], + cwd: elsewhere, + env: Deno.env.toObject(), + }).join(); + expect(documented.stdout).toBe(fromSource.stdout); + // The command's public grammar travels with those bytes. `--run` is gone, // and this directory has no agent to reach and no `DEFAULT_AGENT_NAME` that // resolves here — so a build that still accepted the switch would fail on diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts new file mode 100644 index 00000000..b43ff94b --- /dev/null +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -0,0 +1,149 @@ +/** + * Tier SYN — a JSR consumer's named lookup. + * + * `deno publish --dry-run` listing `components.md` proves the asset is in the + * payload. It does not prove a consumer can *load* it: the module resolves the + * asset from its own URL, and under JSR that URL is a published module URL + * rather than a path in somebody's checkout. Only running a consumer answers + * that. + * + * Staged rather than published, because publishing from a test is not a thing to + * do: the package is copied to a directory outside the workspace, imported by a + * consumer that has no access to this repository's import map, and asked for a + * component's documentation. A build that shipped the module and not the asset, + * or that resolved the asset relative to the process, fails here. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, until } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, writeTextFile } from "@effectionx/fs"; +import { exec } from "@effectionx/process"; +import { timebox } from "@effectionx/timebox"; +import type { ProcessResult } from "@effectionx/process"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const TIMEOUT = 120_000; + +/** The consumer program: import core, build the index, print one entry. */ +const CONSUMER = ` +import { documentationIndexFor } from "@executablemd/core"; +import { main } from "effection"; + +await main(function* () { + const index = yield* documentationIndexFor(); + const elicit = index.documentationFor("Elicit", { + kind: "registered", + origin: "@executablemd/core", + reserved: false, + }); + const syntax = index.documentationFor("Syntax", { + kind: "protected", + origin: "@executablemd/core", + }); + console.log(JSON.stringify({ elicit, syntax })); +}); +`; + +describe("Tier SYN — a staged JSR consumer", () => { + it("SYN47: loads the packaged documentation from the published module layout", function* () { + const staged = yield* until(mkdtemp(path.join(tmpdir(), "xmd-jsr-consumer-"))); + yield* ensure(function* () { + yield* until(rm(staged, { recursive: true, force: true })); + }); + + // The packages as JSR would ship them: source trees, without the + // repository's own tooling, node_modules or tests. Core's own workspace + // siblings come too, because a JSR consumer resolves those as published + // dependencies rather than as directories in somebody's checkout — and it + // is core's asset resolution under test, not its dependency graph. + const SIBLINGS = ["core", "runtime", "durable-streams", "acp"]; + const staging: Record = {}; + for (const name of SIBLINGS) { + const from = path.join(ROOT, "packages", name); + const to = path.join(staged, name); + yield* until(cp(from, to, { recursive: true })); + for (const excluded of ["npm", "tests", "node_modules"]) { + yield* until(rm(path.join(to, excluded), { recursive: true, force: true })); + } + staging[name] = to; + } + const pkg = staging.core ?? ""; + + // A consumer with an import map of its own, naming the staged package by + // path — which is what an installed JSR dependency looks like from the + // consumer's side: a module tree somewhere else entirely. + const consumer = path.join(staged, "consumer"); + yield* ensureDir(consumer); + yield* writeTextFile(path.join(consumer, "main.ts"), CONSUMER); + const rootImports = JSON.parse( + yield* until(Deno.readTextFile(path.join(ROOT, "deno.json"))), + ) as { imports: Record }; + const imports: Record = {}; + for (const [name, target] of Object.entries(rootImports.imports)) { + if (target.startsWith("npm:") || target.startsWith("jsr:") || target.startsWith("http")) { + imports[name] = target; + } + } + // Each staged package's own `exports`, which is what a resolver uses: a + // subpath like `@executablemd/runtime/files` names an export entry, not a + // file called `files`. + for (const [name, dir] of Object.entries(staging)) { + const manifest = JSON.parse(yield* until(Deno.readTextFile(path.join(dir, "deno.json")))) as { + exports?: Record | string; + }; + const exportsMap = + typeof manifest.exports === "string" ? { ".": manifest.exports } : (manifest.exports ?? {}); + for (const [subpath, target] of Object.entries(exportsMap)) { + const specifier = + subpath === "." + ? `@executablemd/${name}` + : `@executablemd/${name}/${subpath.replace(/^\.\//, "")}`; + imports[specifier] = path.join(dir, target); + } + } + yield* writeTextFile(path.join(consumer, "deno.json"), JSON.stringify({ imports }, null, 2)); + + const run = yield* timebox(TIMEOUT, function* () { + return yield* exec(Deno.execPath(), { + arguments: ["run", "--allow-all", "main.ts"], + cwd: consumer, + env: Deno.env.toObject(), + }).join(); + }); + if (run.timeout) { + throw new Error("the staged JSR consumer timed out"); + } + if (run.value.code !== 0) { + throw new Error(`the staged JSR consumer exited ${run.value.code}\n${run.value.stderr}`); + } + + const loaded = JSON.parse(run.value.stdout) as { + elicit?: string; + syntax?: string; + }; + + // The asset loaded from the staged tree, not from this checkout: the + // consumer's working directory is elsewhere and its import map names no + // path inside the repository. + expect(loaded.elicit).toContain("Asks a person a structured question"); + expect(loaded.syntax).toContain("Renders the catalog of components"); + + // And byte-for-byte what the source tree serves for the same component. + const fromSource = yield* timebox(TIMEOUT, function* () { + return yield* exec(Deno.execPath(), { + arguments: ["run", "-A", path.join(ROOT, "packages/cli/src/deno.ts"), "syntax", "Elicit"], + cwd: ROOT, + }).join(); + }); + if (fromSource.timeout) { + throw new Error("the source CLI timed out"); + } + expect(fromSource.value.stdout).toContain(loaded.elicit ?? ""); + }); +}); From 9d92bcbe9ad5e33a9cbd4fc3bf7740e7cbe01f97 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 06:50:35 -0400 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=A7=BE=20Cancel=20inside=20the=20in?= =?UTF-8?q?dex,=20and=20stage=20what=20publish=20actually=20selects=20(#75?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **SYN46 suspended in the wrong operation.** It stood in catalog discovery, which runs before the documentation work and proves nothing about it. There was no seam inside index construction to stand in, so this adds one: a module-private asset reader in `component-documentation.ts`, substitutable only through the source module and deliberately absent from `mod.ts` — not a provider, not a Context, not a package hook, so nothing a document or an installed package reaches can replace it. SYN46 now suspends while the named lookup is reading the packaged asset, with the catalog already built and the durable operation already open. Bypassing index construction makes it fail. **SYN47 was proving less than it claimed.** A recursive copy of a source directory would pass even if the publish filter dropped every asset, so it was not publication evidence. It now runs `deno publish --dry-run` for each package, asserts the assets are in what the filter *selected*, and stages exactly those files — so a filter that excluded an asset fails at staging rather than being copied around it. The consumer writes ordinary XMD and invokes the public `` surface rather than calling the index directly, and its complete rendered output is compared with the same program resolved against the workspace source: one document, one profile, two resolutions, so the only thing the comparison can differ on is the distribution. The casts are gone; both manifests parse through validated schemas. **One exclusion I nearly got wrong.** I excluded `documentation-validation.test.ts` from Node and Bun on the reasoning that its subject is a Deno entrypoint. It is not: the test spawns `deno run` as a subprocess, which works from any runtime, and it passes under Bun. Excluding it would have lost portable coverage for no reason, so the exclusion is removed and only the genuinely Deno-specific JSR probe is excluded — verified by running both files directly under Node and Bun. --- packages/core/src/component-documentation.ts | 35 ++- packages/core/tests/syntax-component.test.ts | 41 +-- scripts/runtime-test-exclusions.ts | 6 + .../tests/jsr-consumer-documentation.test.ts | 285 ++++++++++++------ 4 files changed, 253 insertions(+), 114 deletions(-) diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index 2a09862f..bebfc18b 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -78,7 +78,7 @@ export function* readCoreDocumentation(): Operation { // out of its own package, so it goes to the filesystem directly, at a URL // derived from this module — package-relative whatever the working // directory and search path are. - text: yield* readTextFile(url), + text: yield* readAsset(url), }; } catch (error) { throw new Error( @@ -107,13 +107,44 @@ export function* packageDocumentation( }; } +/** + * What reads a packaged asset, so a test can suspend inside index construction. + * + * Module-private and deliberately not re-exported from `mod.ts`: it is not a + * provider, not a Context and not a package hook, so nothing a document, a + * component or an installed package can reach replaces it. What it exists for + * is evidence — cancelling *inside the documentation work* is a different claim + * from cancelling inside catalog discovery, and there is no other point in this + * operation a test can stand at. + */ +let readAsset: (url: URL) => Operation = readTextFile; + +/** + * Substitute the asset reader for the duration of `body`, then restore it. + * + * Called by core's own tests through the source module, never through the + * package's public surface. + */ +export function* withAssetReader( + reader: (url: URL) => Operation, + body: () => Operation, +): Operation { + const previous = readAsset; + readAsset = reader; + try { + return yield* body(); + } finally { + readAsset = previous; + } +} + /** One packaged documentation asset, read the same guarded way. */ export function* readPackagedDocumentation( url: URL, named: { owner: string; asset: string }, ): Operation { try { - return { ...named, text: yield* readTextFile(url) }; + return { ...named, text: yield* readAsset(url) }; } catch (error) { throw new Error( `the packaged component documentation ${named.asset} is missing from this build ` + diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index c1dcb886..9fea9160 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -51,6 +51,7 @@ import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; import { fixedCatalogObservation, rootCatalogObservation } from "../src/syntax-observation.ts"; import type { CatalogObservation } from "../src/syntax-observation.ts"; +import { withAssetReader } from "../src/component-documentation.ts"; import type { DocumentationContribution } from "../src/component-documentation.ts"; import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; @@ -411,32 +412,32 @@ describe("Tier SYN — the named form", () => { const torn: string[] = []; const stream = new InMemoryStream(); - // A host whose catalog contribution suspends: the named form is inside the - // observation when the scope is cancelled, which is the window a record - // could be written in. - const suspending: ExecutionInstallation = { - *catalog(): Operation { - // Entered *inside* the named documentation operation: the component has - // claimed its occurrence and opened its durable operation by the time - // this runs, so a cancellation that arrives now is one that landed in - // the work rather than before it. + // Suspended inside *documentation-index construction*, not inside catalog + // discovery. By the time this runs the catalog is built, the occurrence is + // claimed and the durable operation is open, and the named lookup is + // reading the packaged asset — which is the window a record could be + // written in, and is reachable only from inside the documentation work. + // A lookup that skipped index construction would never enter it at all. + yield* withAssetReader( + function* (): Operation { torn.push("entered"); yield* ensure(() => { torn.push("torn down"); }); yield* suspend(); - return catalogOf("Unreachable"); + return "## Elicit\n\nunreachable\n"; }, - }; - - yield* scoped(function* () { - const task = yield* spawn(function* () { - return yield* run('\n', [suspending], stream); - }); - // Let the observation get inside its operation before cancelling it. - yield* sleep(20); - yield* task.halt(); - }); + function* () { + yield* scoped(function* () { + const task = yield* spawn(function* () { + return yield* run('\n', [], stream); + }); + // Let the lookup get inside the index before cancelling it. + yield* sleep(20); + yield* task.halt(); + }); + }, + ); const events = yield* stream.readAll(); // Reached the work, then tore it down — in that order. Cancelling before diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index a59e8b0c..c14db293 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -47,6 +47,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "builds the npm package with dnt, which only runs under Deno; the test calls Deno.readTextFileSync", issue: DERIVED_SCOPE, }, + { + path: "scripts/tests/jsr-consumer-documentation.test.ts", + reason: + "its subject is the JSR distribution: it runs `deno publish --dry-run` to learn what the publish filter selects, stages exactly those files, and runs a consumer under `deno run` against them. Every step is Deno's own packaging, so there is nothing here a Node or Bun run would be exercising", + issue: DERIVED_SCOPE, + }, { path: "scripts/tests/adapter-distribution.test.ts", reason: diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index b43ff94b..a4b9f414 100644 --- a/scripts/tests/jsr-consumer-documentation.test.ts +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -1,17 +1,18 @@ /** * Tier SYN — a JSR consumer's named lookup. * - * `deno publish --dry-run` listing `components.md` proves the asset is in the - * payload. It does not prove a consumer can *load* it: the module resolves the - * asset from its own URL, and under JSR that URL is a published module URL - * rather than a path in somebody's checkout. Only running a consumer answers - * that. + * Two claims, and they are different. That `components.md` appears in + * `deno publish --dry-run` says the asset is *selected into the payload* — the + * publish filter keeps it, rather than dropping it as tooling. That a consumer + * can render it says the asset is *reachable from the published layout*, which + * is a fact about how the module resolves it and not about the file list. * - * Staged rather than published, because publishing from a test is not a thing to - * do: the package is copied to a directory outside the workspace, imported by a - * consumer that has no access to this repository's import map, and asked for a - * component's documentation. A build that shipped the module and not the asset, - * or that resolved the asset relative to the process, fails here. + * So this proves both, in that order, and stages exactly what publish selected + * rather than copying a source directory: a recursive copy would pass even if + * the publish filter excluded every asset. The consumer then writes ordinary + * XMD and invokes the public `` surface, because that is the + * thing an author actually reaches — calling the index directly would skip + * selection, the renderer, availability and the whole component. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -22,92 +23,148 @@ import { ensureDir, writeTextFile } from "@effectionx/fs"; import { exec } from "@effectionx/process"; import { timebox } from "@effectionx/timebox"; import type { ProcessResult } from "@effectionx/process"; -import { cp, mkdtemp, rm } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { z } from "npm:zod@^4"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); -const TIMEOUT = 120_000; - -/** The consumer program: import core, build the index, print one entry. */ -const CONSUMER = ` -import { documentationIndexFor } from "@executablemd/core"; -import { main } from "effection"; - -await main(function* () { - const index = yield* documentationIndexFor(); - const elicit = index.documentationFor("Elicit", { - kind: "registered", - origin: "@executablemd/core", - reserved: false, - }); - const syntax = index.documentationFor("Syntax", { - kind: "protected", - origin: "@executablemd/core", - }); - console.log(JSON.stringify({ elicit, syntax })); +const TIMEOUT = 180_000; + +/** The workspace members a consumer of core has to resolve. */ +const MEMBERS = ["core", "runtime", "durable-streams", "acp"] as const; + +/** Every documentation asset the product ships, by package-relative path. */ +const ASSETS: Record = { + core: ["src/components/components.md", "src/agent/components.md"], +}; + +/** A package manifest, only as far as this needs it. */ +const Manifest = z.object({ + exports: z.union([z.string(), z.record(z.string(), z.string())]), }); -`; + +/** The document the consumer runs: the public surface, nothing else. */ +const CONSUMER_DOCUMENT = '\n'; + +/** Which files `deno publish` would actually send, for one package. */ +function* publishedFiles(pkg: string): Operation { + const dry = yield* timebox(TIMEOUT, function* () { + return yield* exec(Deno.execPath(), { + // Not `--quiet`: the file listing *is* the evidence, and quiet suppresses + // exactly the lines this reads. + arguments: ["publish", "--dry-run", "--allow-dirty"], + cwd: path.join(ROOT, "packages", pkg), + env: Deno.env.toObject(), + }).join(); + }); + if (dry.timeout) { + throw new Error(`deno publish --dry-run timed out for ${pkg}`); + } + const listed: string[] = []; + for (const line of `${dry.value.stdout}\n${dry.value.stderr}`.split("\n")) { + const trimmed = line.trim(); + const marker = trimmed.indexOf("file:///"); + if (marker === -1) { + continue; + } + const url = trimmed.slice(marker).split(" ")[0] ?? ""; + listed.push(fileURLToPath(url)); + } + return listed; +} describe("Tier SYN — a staged JSR consumer", () => { - it("SYN47: loads the packaged documentation from the published module layout", function* () { + it("SYN47: publishes the documentation assets and renders them for a consumer", function* () { + // 1. The publish filter selects the assets. This is the payload claim, and + // it is checked against the real `deno publish` selection rather than + // against a directory listing. + const selected = yield* publishedFiles("core"); + expect(selected.length).toBeGreaterThan(0); + for (const asset of ASSETS.core ?? []) { + const expected = path.join(ROOT, "packages/core", asset); + expect([asset, selected.includes(expected)]).toEqual([asset, true]); + } + const staged = yield* until(mkdtemp(path.join(tmpdir(), "xmd-jsr-consumer-"))); yield* ensure(function* () { yield* until(rm(staged, { recursive: true, force: true })); }); - // The packages as JSR would ship them: source trees, without the - // repository's own tooling, node_modules or tests. Core's own workspace - // siblings come too, because a JSR consumer resolves those as published - // dependencies rather than as directories in somebody's checkout — and it - // is core's asset resolution under test, not its dependency graph. - const SIBLINGS = ["core", "runtime", "durable-streams", "acp"]; + // 2. Stage exactly what publish selected, file by file. A recursive copy + // would pass even if the filter dropped every asset, which is the thing + // this case exists to catch. const staging: Record = {}; - for (const name of SIBLINGS) { - const from = path.join(ROOT, "packages", name); - const to = path.join(staged, name); - yield* until(cp(from, to, { recursive: true })); - for (const excluded of ["npm", "tests", "node_modules"]) { - yield* until(rm(path.join(to, excluded), { recursive: true, force: true })); + for (const member of MEMBERS) { + const from = path.join(ROOT, "packages", member); + const to = path.join(staged, member); + const files = member === "core" ? selected : yield* publishedFiles(member); + for (const file of files) { + const relative = path.relative(from, file); + if (relative.startsWith("..")) { + continue; + } + const target = path.join(to, relative); + yield* until(mkdir(path.dirname(target), { recursive: true })); + yield* until(cp(file, target)); } - staging[name] = to; + staging[member] = to; } - const pkg = staging.core ?? ""; - // A consumer with an import map of its own, naming the staged package by - // path — which is what an installed JSR dependency looks like from the - // consumer's side: a module tree somewhere else entirely. + // Every asset is in the staged tree because publish selected it, not + // because a copy swept the directory. + for (const asset of ASSETS.core ?? []) { + const stagedAsset = path.join(staging.core ?? "", asset); + expect([asset, yield* exists(stagedAsset)]).toEqual([asset, true]); + } + + // 3. A consumer outside the repository, resolving the staged packages + // through an import map of its own that names no path in this checkout. const consumer = path.join(staged, "consumer"); yield* ensureDir(consumer); - yield* writeTextFile(path.join(consumer, "main.ts"), CONSUMER); - const rootImports = JSON.parse( - yield* until(Deno.readTextFile(path.join(ROOT, "deno.json"))), - ) as { imports: Record }; - const imports: Record = {}; - for (const [name, target] of Object.entries(rootImports.imports)) { - if (target.startsWith("npm:") || target.startsWith("jsr:") || target.startsWith("http")) { - imports[name] = target; - } - } - // Each staged package's own `exports`, which is what a resolver uses: a - // subpath like `@executablemd/runtime/files` names an export entry, not a - // file called `files`. - for (const [name, dir] of Object.entries(staging)) { - const manifest = JSON.parse(yield* until(Deno.readTextFile(path.join(dir, "deno.json")))) as { - exports?: Record | string; - }; - const exportsMap = - typeof manifest.exports === "string" ? { ".": manifest.exports } : (manifest.exports ?? {}); - for (const [subpath, target] of Object.entries(exportsMap)) { - const specifier = - subpath === "." - ? `@executablemd/${name}` - : `@executablemd/${name}/${subpath.replace(/^\.\//, "")}`; - imports[specifier] = path.join(dir, target); - } - } + yield* writeTextFile(path.join(consumer, "document.md"), CONSUMER_DOCUMENT); + const imports = yield* consumerImports(staging); yield* writeTextFile(path.join(consumer, "deno.json"), JSON.stringify({ imports }, null, 2)); + yield* writeTextFile( + path.join(consumer, "main.ts"), + [ + "// The public surface, assembled the way a consumer would: core's own", + "// registrations plus its Agent boundary, so the document can name a", + "// component from each of the two documentation assets.", + "import {", + " AGENT_REGISTRATIONS,", + " agentDocumentation,", + " collect,", + " registerComponents,", + '} from "@executablemd/core";', + "// The host boundary is its own entrypoint, and a consumer reaches it", + "// the same way: `@executablemd/core/host`.", + 'import { executeInstalled } from "@executablemd/core/host";', + 'import { InMemoryStream } from "@executablemd/durable-streams";', + 'import { main, scoped, until } from "effection";', + 'import { readFile } from "node:fs/promises";', + "", + "await main(function* () {", + ' const content = yield* until(readFile("document.md", "utf8"));', + " const rendered = yield* scoped(function* () {", + " yield* registerComponents(AGENT_REGISTRATIONS);", + " return yield* collect(", + " yield* executeInstalled(", + " {", + ' path: "document.md",', + " content,", + " stream: new InMemoryStream(),", + " includes: [],", + " },", + " [{ documentation: [yield* agentDocumentation()] }],", + " ),", + " );", + " });", + " console.log(String(rendered));", + "});", + ].join("\n"), + ); const run = yield* timebox(TIMEOUT, function* () { return yield* exec(Deno.execPath(), { @@ -123,27 +180,71 @@ describe("Tier SYN — a staged JSR consumer", () => { throw new Error(`the staged JSR consumer exited ${run.value.code}\n${run.value.stderr}`); } - const loaded = JSON.parse(run.value.stdout) as { - elicit?: string; - syntax?: string; - }; - - // The asset loaded from the staged tree, not from this checkout: the - // consumer's working directory is elsewhere and its import map names no - // path inside the repository. - expect(loaded.elicit).toContain("Asks a person a structured question"); - expect(loaded.syntax).toContain("Renders the catalog of components"); - - // And byte-for-byte what the source tree serves for the same component. + // 4. The same program, resolved against the workspace source instead of the + // staged packages. One document, one profile, two resolutions — so the + // only thing the comparison can differ on is the distribution, which is + // exactly what is under test. Comparing against a different profile + // would compare two catalogs and prove nothing about packaging. const fromSource = yield* timebox(TIMEOUT, function* () { return yield* exec(Deno.execPath(), { - arguments: ["run", "-A", path.join(ROOT, "packages/cli/src/deno.ts"), "syntax", "Elicit"], - cwd: ROOT, + arguments: ["run", "--allow-all", "--config", path.join(ROOT, "deno.json"), "main.ts"], + cwd: consumer, + env: Deno.env.toObject(), }).join(); }); if (fromSource.timeout) { - throw new Error("the source CLI timed out"); + throw new Error("the source surface timed out"); } - expect(fromSource.value.stdout).toContain(loaded.elicit ?? ""); + + expect(run.value.stdout.trimEnd()).toBe(fromSource.value.stdout.trimEnd()); + // And it is a real answer: both components, from two different asset + // files, with their metadata and availability. + expect(run.value.stdout).toContain("### ``"); + expect(run.value.stdout).toContain("### ``"); + expect(run.value.stdout).toContain("Asks a person a structured question"); + expect(run.value.stdout).toContain("Sends a prompt and renders the reply"); + expect(run.value.stdout).toContain("`@executablemd/core` (registered default)"); + expect(run.value.stdout).toContain("**Available in this evaluation:** yes"); }); }); + +/** The import map a consumer of the staged packages writes. */ +function* consumerImports(staging: Record): Operation> { + const rootManifest = z + .object({ imports: z.record(z.string(), z.string()) }) + .parse(JSON.parse(yield* until(Deno.readTextFile(path.join(ROOT, "deno.json"))))); + + const imports: Record = {}; + // External dependencies resolve as they would for any consumer; workspace + // paths do not travel, which is the point of staging. + for (const [name, target] of Object.entries(rootManifest.imports)) { + if (target.startsWith("npm:") || target.startsWith("jsr:") || target.startsWith("http")) { + imports[name] = target; + } + } + for (const [name, dir] of Object.entries(staging)) { + const manifest = Manifest.parse( + JSON.parse(yield* until(Deno.readTextFile(path.join(dir, "deno.json")))), + ); + const exported = + typeof manifest.exports === "string" ? { ".": manifest.exports } : manifest.exports; + for (const [subpath, target] of Object.entries(exported)) { + const specifier = + subpath === "." + ? `@executablemd/${name}` + : `@executablemd/${name}/${subpath.replace(/^\.\//, "")}`; + imports[specifier] = path.join(dir, target); + } + } + return imports; +} + +/** Whether a staged path is present. */ +function* exists(target: string): Operation { + try { + yield* until(Deno.stat(target)); + return true; + } catch { + return false; + } +} From d9985f1cd0b922186f5558ae4479adbf8f701f26 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 07:05:23 -0400 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=A7=B5=20Give=20each=20execution=20?= =?UTF-8?q?its=20own=20packaged-asset=20reader=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam I added for SYN46 was a module-scoped mutable variable with an exported setter. One variable shared by every execution in the process: two runs would read through each other's reader, and substituting one changed what an unrelated execution was told the product says. A test-only intent does not make shared mutable state execution-local. The reader is now a value. It travels from where the execution is built — `runInvocation` → `invoke` → `executeDocument` → the observation — and the observation holds its own, so narrowing carries it and two executions in one process cannot reach each other's. There is no setter, no Context, no provider, no installation field and no hook: nothing a document, a component or an installed package can name reaches it at all. Production still uses the direct Effection filesystem. `executeReadingAssetsWith()` replaces `withAssetReader()`: it *builds a new execution* around a reader rather than changing anything an existing one holds, and is exported from `src/execute.ts` alone — not from `mod.ts`, not from `host.ts`. Importing it from a repository component gives you the ability to start your own execution, which you already had; it gives you no way to touch the current one's reader. SYN48 is the control that makes this a fact rather than a claim: one execution suspended inside documentation-index construction, a second ordinary execution overlapping it in the same process. The ordinary one reads canonical documentation and completes independently. Restoring the module-global implementation makes SYN48 fail, which is the check I should have written the first time — SYN46 alone passes under both designs. --- packages/core/src/component-documentation.ts | 72 ++++++------- packages/core/src/execute.ts | 42 +++++++- packages/core/src/syntax-observation.ts | 23 ++++- packages/core/tests/syntax-component.test.ts | 100 +++++++++++++++---- 4 files changed, 172 insertions(+), 65 deletions(-) diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index bebfc18b..89ec3e97 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -47,12 +47,15 @@ export function agentDocumentationUrl(): URL { * Agent components to document, and demanding their documentation would refuse * an index for a profile that is complete without them. */ -export function* agentDocumentation(): Operation { +export function* agentDocumentation( + read: DocumentationReader = packagedAssetReader, +): Operation { return { - source: yield* readPackagedDocumentation(agentDocumentationUrl(), { - owner: CORE_ORIGIN, - asset: "packages/core/src/agent/components.md", - }), + source: yield* readPackagedDocumentation( + agentDocumentationUrl(), + { owner: CORE_ORIGIN, asset: "packages/core/src/agent/components.md" }, + read, + ), supplies: AGENT_COMPONENT_NAMES, }; } @@ -64,7 +67,9 @@ const AGENT_COMPONENT_NAMES: ReadonlySet = new Set([ ]); /** Core's documentation source, read from the package rather than the caller. */ -export function* readCoreDocumentation(): Operation { +export function* readCoreDocumentation( + read: DocumentationReader = packagedAssetReader, +): Operation { const url = componentDocumentationUrl(); try { return { @@ -78,7 +83,7 @@ export function* readCoreDocumentation(): Operation { // out of its own package, so it goes to the filesystem directly, at a URL // derived from this module — package-relative whatever the working // directory and search path are. - text: yield* readAsset(url), + text: yield* read(url), }; } catch (error) { throw new Error( @@ -100,51 +105,38 @@ export function* packageDocumentation( url: URL, named: { owner: string; asset: string }, supplies: Iterable, + read: DocumentationReader = packagedAssetReader, ): Operation { return { - source: yield* readPackagedDocumentation(url, named), + source: yield* readPackagedDocumentation(url, named, read), supplies: new Set(supplies), }; } /** - * What reads a packaged asset, so a test can suspend inside index construction. + * How one execution reads its packaged assets. * - * Module-private and deliberately not re-exported from `mod.ts`: it is not a - * provider, not a Context and not a package hook, so nothing a document, a - * component or an installed package can reach replaces it. What it exists for - * is evidence — cancelling *inside the documentation work* is a different claim - * from cancelling inside catalog discovery, and there is no other point in this - * operation a test can stand at. + * Carried by value from where the execution is built, never held in module + * scope. A module-level reader would be one variable shared by every execution + * in the process: two runs in one process would read through each other's, and + * a test that substituted one would change what an unrelated execution is told + * the product says. It is also not a provider, a Context, an installation field + * or a hook — nothing a document, a component or an installed package can reach + * names it at all. */ -let readAsset: (url: URL) => Operation = readTextFile; +export type DocumentationReader = (url: URL) => Operation; -/** - * Substitute the asset reader for the duration of `body`, then restore it. - * - * Called by core's own tests through the source module, never through the - * package's public surface. - */ -export function* withAssetReader( - reader: (url: URL) => Operation, - body: () => Operation, -): Operation { - const previous = readAsset; - readAsset = reader; - try { - return yield* body(); - } finally { - readAsset = previous; - } -} +/** The reader every ordinary execution uses. */ +export const packagedAssetReader: DocumentationReader = readTextFile; /** One packaged documentation asset, read the same guarded way. */ export function* readPackagedDocumentation( url: URL, named: { owner: string; asset: string }, + read: DocumentationReader = packagedAssetReader, ): Operation { try { - return { ...named, text: yield* readAsset(url) }; + return { ...named, text: yield* read(url) }; } catch (error) { throw new Error( `the packaged component documentation ${named.asset} is missing from this build ` + @@ -177,9 +169,17 @@ export function* documentationIndexFor( * could hide the documentation of a component it does. */ contributed: readonly DocumentationContribution[] = [], + /** + * How this execution reads core's own asset. + * + * By value, from whoever built the observation. Nothing module-scoped, so two + * executions in one process each read through their own and neither can + * change what the other is told. + */ + read: DocumentationReader = packagedAssetReader, ): Operation { const core: DocumentationContribution = { - source: yield* readCoreDocumentation(), + source: yield* readCoreDocumentation(read), supplies: CORE_COMPONENT_NAMES, }; const all = [core, ...contributed]; diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 4ae7ba0c..32692d82 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -141,7 +141,8 @@ import { ExecutionImports } from "./components/import-authority.ts"; import type { ExpansionAuthority, ImportTier } from "./components/import-authority.ts"; import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; import { rootCatalogObservation, snapshotContributions } from "./syntax-observation.ts"; -import type { DocumentationContribution } from "./component-documentation.ts"; +import { packagedAssetReader } from "./component-documentation.ts"; +import type { DocumentationContribution, DocumentationReader } from "./component-documentation.ts"; import type { CatalogContribution } from "./syntax-observation.ts"; import type { WorkflowComponentBundle, WorkflowImportAuthority } from "./components/bundle.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; @@ -2161,6 +2162,8 @@ function* executeDocument( * the profile actually assembled. */ documentation: readonly DocumentationContribution[] = [], + /** This execution's packaged-asset reader, carried by value from the caller. */ + readAsset: DocumentationReader = packagedAssetReader, ): Operation { const { stream, @@ -2348,6 +2351,7 @@ function* executeDocument( }, catalogs[0], documentation, + readAsset, ), }; @@ -2735,6 +2739,13 @@ export const Execution: Api = createApi("Execution", function* runInvocation( options: ExecuteOptions, installations: readonly ExecutionInstallation[], + /** + * How this execution reads its packaged documentation assets. + * + * Defaulted to the real filesystem reader and carried by value from here into + * the observation, so it belongs to this execution alone. + */ + readAsset: DocumentationReader = packagedAssetReader, observed?: () => void, ): Operation { const ready = withResolvers(); @@ -2767,7 +2778,7 @@ function* runInvocation( let published = false; try { yield* scoped(function* () { - const execution = yield* invoke(options, installations); + const execution = yield* invoke(options, installations, readAsset); published = true; ready.resolve(execution); state.document = yield* execution; @@ -2953,6 +2964,8 @@ function detachedSchema(schema: Sche function* invoke( options: ExecuteOptions, installations: readonly ExecutionInstallation[], + /** This execution's packaged-asset reader, carried by value from its caller. */ + readAsset: DocumentationReader = packagedAssetReader, ): Operation { const admissions = Object.freeze( installations.flatMap((installation) => [...(installation.admissions ?? [])]), @@ -3115,6 +3128,7 @@ function* invoke( declarations, catalogs, documentation, + readAsset, ); } @@ -3152,7 +3166,7 @@ export function executeObserved( ): Operation { // The callback is read here, once, and passed on as a value. What the caller // does to its own record afterwards is its own business. - return runInvocation(options, [...installations], observers.observed); + return runInvocation(options, [...installations], packagedAssetReader, observers.observed); } /** @@ -3170,6 +3184,28 @@ export function executeInstalled( return runInvocation(options, [...installations]); } +/** + * One execution whose packaged-asset reads go through `read`. + * + * Core's own evidence seam, exported from this module and from neither `mod.ts` + * nor `host.ts`, so it is not part of the package's surface. It *builds a new + * execution* around the reader rather than changing anything an existing one + * holds — so importing it from a repository component, an installed package or + * a document's own code cannot reach the current execution's reader, and two + * executions in one process are unaffected by each other. + * + * It exists because cancelling *inside documentation-index construction* is a + * different claim from cancelling inside catalog discovery, and there is no + * other point in that operation a test can stand at. + */ +export function executeReadingAssetsWith( + options: ExecuteOptions, + installations: readonly ExecutionInstallation[], + readAsset: DocumentationReader, +): Operation { + return runInvocation(options, [...installations], readAsset); +} + /** * Apply every additive completion policy, in registration order. * diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 8c43e379..124afdca 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -34,8 +34,8 @@ import { inspectSyntax } from "./inspect.ts"; import type { SyntaxCatalog } from "./inspect.ts"; import { renderSelectedDocumentation, renderSyntaxMarkdown } from "./syntax-markdown.ts"; import type { SelectedEntry } from "./syntax-markdown.ts"; -import { documentationIndexFor } from "./component-documentation.ts"; -import type { DocumentationContribution } from "./component-documentation.ts"; +import { documentationIndexFor, packagedAssetReader } from "./component-documentation.ts"; +import type { DocumentationContribution, DocumentationReader } from "./component-documentation.ts"; import type { DocumentationIndex } from "./documentation-index.ts"; import { UnknownComponentError } from "./documentation-index.ts"; import type { WorkflowImportAuthority } from "./components/bundle.ts"; @@ -133,6 +133,16 @@ export function rootCatalogObservation( * fallback sentence inside a document. */ documentation: readonly DocumentationContribution[] = [], + /** + * How this execution reads its packaged assets. + * + * Held by the observation, so it belongs to this execution and no other. It + * reaches here from where the execution was built and from nowhere else — + * there is no setter, no context and no installation field that names it, so + * a document, a component or an installed package cannot substitute one, and + * a second execution in the same process is unaffected by this one's. + */ + read: DocumentationReader = packagedAssetReader, ): CatalogObservation { function* current(): Operation { return contribution === undefined ? yield* derived(inputs) : yield* contribution(); @@ -141,7 +151,7 @@ export function rootCatalogObservation( // ones the installation boundary captured rather than whatever the caller's // objects hold by the time a document asks. const captured = snapshotContributions(documentation); - return observing(current, current, captured); + return observing(current, current, captured, read); } /** @@ -155,6 +165,7 @@ function observing( reference: () => Operation, executable: () => Operation, documentation: readonly DocumentationContribution[], + read: DocumentationReader, ): CatalogObservation { return { *observe(): Operation { @@ -163,7 +174,7 @@ function observing( *document(names: readonly string[]): Operation { const authoring = yield* reference(); const runnable = yield* executable(); - const index = yield* documentationIndexFor(documentation); + const index = yield* documentationIndexFor(documentation, read); return renderSelectedDocumentation(select(authoring, runnable, names, index)); }, narrow(admitted: SyntaxCatalog): CatalogObservation { @@ -178,6 +189,7 @@ function observing( return admitted; }, documentation, + read, ); }, }; @@ -341,6 +353,8 @@ export function fixedCatalogObservation( * installs the executable catalog; this is the index that goes with it. */ documentation: readonly DocumentationContribution[] = [], + /** How this observation reads packaged assets — this execution's, by value. */ + read: DocumentationReader = packagedAssetReader, ): CatalogObservation { const captured = snapshotContributions(documentation); return observing( @@ -353,5 +367,6 @@ export function fixedCatalogObservation( return catalog; }, captured, + read, ); } diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 9fea9160..e4a7661d 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -27,7 +27,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped, sleep, spawn, suspend, until } from "effection"; +import { ensure, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; @@ -51,7 +51,7 @@ import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; import { fixedCatalogObservation, rootCatalogObservation } from "../src/syntax-observation.ts"; import type { CatalogObservation } from "../src/syntax-observation.ts"; -import { withAssetReader } from "../src/component-documentation.ts"; +import { executeReadingAssetsWith } from "../src/execute.ts"; import type { DocumentationContribution } from "../src/component-documentation.ts"; import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; @@ -418,26 +418,36 @@ describe("Tier SYN — the named form", () => { // reading the packaged asset — which is the window a record could be // written in, and is reachable only from inside the documentation work. // A lookup that skipped index construction would never enter it at all. - yield* withAssetReader( - function* (): Operation { - torn.push("entered"); - yield* ensure(() => { - torn.push("torn down"); - }); - yield* suspend(); - return "## Elicit\n\nunreachable\n"; - }, - function* () { - yield* scoped(function* () { - const task = yield* spawn(function* () { - return yield* run('\n', [], stream); - }); - // Let the lookup get inside the index before cancelling it. - yield* sleep(20); - yield* task.halt(); - }); - }, - ); + // The reader belongs to *this* execution, handed to it at construction. + // Nothing module-scoped: a second execution in this process reads through + // its own, which SYN48 below is about. + const suspending = function* (): Operation { + torn.push("entered"); + yield* ensure(() => { + torn.push("torn down"); + }); + yield* suspend(); + return "## Elicit\n\nunreachable\n"; + }; + + yield* scoped(function* () { + const task = yield* spawn(function* () { + return yield* collect( + yield* executeReadingAssetsWith( + { + ...retainedSource(ROOT_PATH, '\n'), + stream, + includes: [], + }, + [], + suspending, + ), + ); + }); + // Let the lookup get inside the index before cancelling it. + yield* sleep(20); + yield* task.halt(); + }); const events = yield* stream.readAll(); // Reached the work, then tore it down — in that order. Cancelling before @@ -489,6 +499,52 @@ describe("Tier SYN — the named form", () => { expect(rendered).not.toContain("Substituted"); }); + it("SYN48: an ordinary observation is unaffected by another execution's suspended one", function* () { + // Two executions overlapping in one process. One is stopped inside + // documentation-index construction; the other is ordinary and must read + // canonical documentation and finish on its own. + // + // This is what a module-scoped reader gets wrong: one variable shared by + // every execution means the suspended one's substitution is what the + // ordinary one reads, and it would either hang on the same suspend or + // render the substituted prose. Against the module-global implementation at + // 9d92bcbe this fails. + const entered = withResolvers(); + const stream = new InMemoryStream(); + + const ordinary = yield* scoped(function* () { + const held = yield* spawn(function* () { + return yield* collect( + yield* executeReadingAssetsWith( + { + ...retainedSource(ROOT_PATH, '\n'), + stream, + includes: [], + }, + [], + function* (): Operation { + entered.resolve(); + yield* suspend(); + return "## Elicit\n\nSUBSTITUTED BY THE OTHER EXECUTION.\n"; + }, + ), + ); + }); + + // Only once the first execution is genuinely inside its own index work. + yield* entered.operation; + + // A second, ordinary execution — no reader of its own, so it uses the + // real one. + const rendered = String(yield* run('\n')); + yield* held.halt(); + return rendered; + }); + + expect(ordinary).toContain("Asks a person a structured question"); + expect(ordinary).not.toContain("SUBSTITUTED BY THE OTHER EXECUTION"); + }); + it("SYN39: retains the named text, and a continuation restores it whole", function* () { const stream = new InMemoryStream(); const first = String(yield* run('\n', [], stream)); From e90fe2e163723f6a3f601ac3da2b1216ed6ad8a3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 07:27:16 -0400 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=9A=91=20Authorize=20the=20root=20i?= =?UTF-8?q?mport=20only=20where=20a=20tier=20closes=20it=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found a regression the whole local battery missed. The protected tier is present in every execution, so `ExecutionImports` is now built unconditionally — it used to be `undefined` for a run with no bundle and no declarations. The root import read that as permission to authorize: imports === undefined ? imported : imports.authorize("__root__", imported) With the authority always present, that asks a question nothing answers — `__root__` is claimed by no tier unless a bundle closes the execution — and an ordinary run's root refuses with *this execution authorizes no import of this name*. It now asks only when a tier actually closes the name, which is the rule every other import already followed, and restores the previous behaviour exactly: absent authority and unclosed name both skip authorization, a bundle still closes the execution and still authorizes its root. Also restores the `documentation-validation.test.ts` exclusion I removed last round. My reasoning then was that the test shells out to `deno` and so is portable, and it does pass under a local Bun — because a developer machine has Deno installed. The CI Bun shard does not: every case fails with `Executable not found in $PATH: "deno"`. Needing the `deno` executable is exactly as disqualifying as calling `Deno.*`, and the local pass was the misleading signal. Verified: `deno task test packages/core/tests/` — 352 passed, 0 failed. --- packages/core/src/execute.ts | 10 +++++++++- scripts/runtime-test-exclusions.ts | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 32692d82..c5f8d3b5 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -1900,7 +1900,15 @@ function* documentWorkflow( (function* (): Operation { const imported = yield* importComponent("__root__"); const imports = authority.imports; - return imports === undefined ? imported : imports.authorize("__root__", imported); + // Asked only when a tier actually closes this name, exactly as an + // ordinary import is. The authority used to be absent altogether for a + // run with no bundle and no declarations, so this could authorize + // unconditionally; the protected tier is present in *every* execution, so + // an unguarded call now refuses the root of every ordinary run — nothing + // claims `__root__` unless a bundle closes the execution. + return imports?.closes("__root__") === true + ? imports.authorize("__root__", imported) + : imported; })(), ); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index c14db293..8ef6e4e4 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -47,6 +47,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "builds the npm package with dnt, which only runs under Deno; the test calls Deno.readTextFileSync", issue: DERIVED_SCOPE, }, + { + path: "scripts/tests/documentation-validation.test.ts", + reason: + "runs the repository's build gate as a subprocess — `deno run scripts/validate-documentation.ts` — and the Node and Bun shards have no `deno` on PATH, so every case fails with `Executable not found in $PATH`. The subject is the gate, which is a Deno entrypoint; a local Bun run passes only because a developer machine happens to have Deno installed", + issue: DERIVED_SCOPE, + }, { path: "scripts/tests/jsr-consumer-documentation.test.ts", reason: From ca4152a23080b744227ba59295b6904525f4dca5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 07:44:59 -0400 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=8E=AF=20Resolve=20one=20root=20cat?= =?UTF-8?q?alog=20per=20occurrence,=20and=20document=20what=20a=20child=20?= =?UTF-8?q?registers=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A named root occurrence asked its catalog twice.** `rootCatalogObservation()` supplied `current` as both the reference and the executable operation, and `document()` called both — so one occurrence invoked the trusted catalog contribution twice. Wasteful, and worse than wasteful: the environment can move between the two calls, and an entry's metadata would then come from a different catalog than the availability printed beside it. The observation now takes the authoring catalog and an *optional* admission. At a root there is no admission, so one resolution answers both questions. Under narrowing the two are genuinely different values, and the bare form reports the admission without asking the enclosing catalog at all. SYN49 proves it against a contribution that changes between calls — one occurrence, one call, both decisions from that value — with two occurrences still independent and a continuation restoring the retained text without asking again. **A nested `` was told less than it could run.** `testing-host.ts` installs the run profile's registrations and its `` declaration, but passed no documentation, so a child's index held core's contributions alone: `` rendered the entry for a component the child can execute and then said it was undocumented. The contributions now travel beside the declarations they belong to. Audited the other host assemblies. `upgrade.ts` and `authorship-profile.ts` register no run-profile components, and `plan-component.ts` installs the registry for *validation* rather than an execution, so none of them can drift this way. `cli.ts` and `testing-host.ts` are the two run-profile executions, and both now assemble registrations and documentation from `useRunProfileRegistry()` and `runProfileDocumentation()` side by side. The nested regression names its fixture `lookup.md` rather than `webform.md`: this filesystem is case-insensitive, so a document of that name is found as the repository component `WebForm`, shadows the registration, and makes the fallback correct — the case would have been measuring the wrong thing and passing for it. Built on e90fe2e1 rather than d9985f1c: that commit is the CI fix for the root import, which this keeps. --- packages/cli/src/testing-host.ts | 8 ++++ .../cli/tests/testing-execution-host.test.ts | 46 +++++++++++++++++++ packages/core/src/syntax-observation.ts | 41 +++++++++-------- packages/core/tests/syntax-component.test.ts | 46 +++++++++++++++++++ 4 files changed, 123 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index 526daacf..b2c5c15c 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -58,6 +58,7 @@ import type { TestAgentChildConfiguration, } from "@executablemd/testing"; import { installDocumentComponents } from "./cli.ts"; +import { runProfileDocumentation } from "./syntax.ts"; import type { HostServiceInstaller } from "./cli.ts"; import type { RepositoryInstaller } from "./run-repositories.ts"; @@ -329,6 +330,13 @@ function* runProfileChild( : { observeAuthorship: settings.observePlanAuthorship }), }), ], + // The documentation for the same profile's registrations, beside the + // declarations rather than anywhere else. A child that registered the run + // profile's components without their documentation would answer + // `` with the no-documentation sentence — a + // component it can run, described as undocumented — because the index it + // built would hold core's contributions alone. + documentation: yield* runProfileDocumentation(), }); // A child gets what `xmd run` gets, and the browser form is part of that. // Installed here rather than inherited: this scope is isolated from the diff --git a/packages/cli/tests/testing-execution-host.test.ts b/packages/cli/tests/testing-execution-host.test.ts index ce2993a1..4c899280 100644 --- a/packages/cli/tests/testing-execution-host.test.ts +++ b/packages/cli/tests/testing-execution-host.test.ts @@ -362,6 +362,52 @@ describe("nested execution under the production run host", () => { expect(outer.stdout).toContain("### ``"); }); + it("gives a nested run child the documentation for what it registers", function* () { + // `` is the web package's, which the run profile registers — so a + // child that is the run profile can run it, and must be able to explain it. + // Prose from `packages/web/src/components.md`, not the catalog description + // an entry already carries: a child whose index held core's contributions + // alone would render the entry and then say it is undocumented. + const project = yield* useProject({ + "elsewhere/Greeting.md": doc("hello"), + // Not `webform.md`: this filesystem is case-insensitive, so a document + // of that name is found as the repository component `WebForm` and + // shadows the registration — the fallback would then be correct, and the + // case would be measuring the wrong thing. + "lookup.md": doc(''), + "README.md": doc( + '', + '\\n"} as="child">', + '', + "", + '', + // Unique to the long-form documentation. + '', + // And never the fallback, which is what this regression is about. + "", + // The child's own isolation is unchanged: the outer include still does + // not reach it. + "`/} />", + "", + "", + ), + }); + + const nested = yield* runCli(["test", "README.md", "--include", "elsewhere"], { + cwd: project, + }).join(); + expect(nested.code).toBe(0); + + // The ordinary-run control: both profiles read the same owning package's + // documentation, so the child above is not a special case that happens to + // agree. + const ordinary = yield* runCli(["run", "lookup.md"], { cwd: project }).join(); + expect(ordinary.code).toBe(0); + expect(ordinary.stdout).toContain("### ``"); + expect(ordinary.stdout).toContain("anything awkward to type at a"); + expect(ordinary.stdout).not.toContain("No long-form documentation"); + }); + it("refuses outside a canonical ", function* () { const project = yield* useProject({ "child.md": doc("child"), diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-observation.ts index 124afdca..286112da 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-observation.ts @@ -151,7 +151,10 @@ export function rootCatalogObservation( // ones the installation boundary captured rather than whatever the caller's // objects hold by the time a document asks. const captured = snapshotContributions(documentation); - return observing(current, current, captured, read); + // No admission at a root: nothing has narrowed what may execute, so the one + // catalog this resolves is both what a document may write and what it may + // read about. + return observing(current, undefined, captured, read); } /** @@ -162,35 +165,40 @@ export function rootCatalogObservation( * and replaces the executable, which is the whole of the seam. */ function observing( + /** The authoring catalog: what may be read about here. */ reference: () => Operation, - executable: () => Operation, + /** + * What may *execute* here, when a boundary has narrowed it. + * + * Absent at a root, where the two are the same catalog — and must be the same + * *value*. Resolving twice would call the trusted catalog contribution twice + * for one occurrence, and the environment could move between the two calls: + * an entry's metadata would then come from a different catalog than the + * availability reported beside it. + */ + admitted: SyntaxCatalog | undefined, documentation: readonly DocumentationContribution[], read: DocumentationReader, ): CatalogObservation { return { *observe(): Operation { - return renderSyntaxMarkdown(yield* executable()); + // A narrowed observation reports its admission and asks the enclosing + // catalog for nothing — the bare form is about what runs. + return renderSyntaxMarkdown(admitted ?? (yield* reference())); }, *document(names: readonly string[]): Operation { + // One resolution, both decisions. const authoring = yield* reference(); - const runnable = yield* executable(); + const runnable = admitted ?? authoring; const index = yield* documentationIndexFor(documentation, read); return renderSelectedDocumentation(select(authoring, runnable, names, index)); }, - narrow(admitted: SyntaxCatalog): CatalogObservation { + narrow(next: SyntaxCatalog): CatalogObservation { // The enclosing reference and the enclosing index, unchanged. Only what // may execute is replaced, so a nested author keeps the documentation // they had and every entry reports its availability against the // admission. - // deno-lint-ignore require-yield - return observing( - reference, - function* () { - return admitted; - }, - documentation, - read, - ); + return observing(reference, next, documentation, read); }, }; } @@ -362,10 +370,7 @@ export function fixedCatalogObservation( function* () { return reference; }, - // deno-lint-ignore require-yield - function* () { - return catalog; - }, + catalog, captured, read, ); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index e4a7661d..762b9388 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -545,6 +545,52 @@ describe("Tier SYN — the named form", () => { expect(ordinary).not.toContain("SUBSTITUTED BY THE OTHER EXECUTION"); }); + it("SYN49: a named root occurrence resolves its catalog exactly once", function* () { + // A contribution that *changes* between calls, so a second resolution is + // not merely wasteful but visible: an entry's metadata would come from one + // catalog and the availability beside it from another. + const calls = { count: 0 }; + const moving: ExecutionInstallation = { + // deno-lint-ignore require-yield + *catalog(): Operation { + calls.count += 1; + return catalogOf(`Marker${calls.count}`); + }, + }; + + const stream = new InMemoryStream(); + const first = String(yield* run('\n', [moving], stream)); + + // Once — not once for selection and again for availability. + expect(calls.count).toBe(1); + // And both decisions came from that one value: the entry is rendered, and + // it is available. A second resolution would have produced `Marker2`, + // leaving `Marker1` unselectable or unavailable. + expect(first).toContain("### ``"); + expect(first).toContain("**Available in this evaluation:** yes"); + + // Two occurrences still observe independently: this is one catalog per + // occurrence, not one per execution. + calls.count = 0; + const both = String( + yield* run( + ['', "", '', ""].join("\n"), + [moving], + ), + ); + expect(calls.count).toBe(2); + expect(both).toContain("### ``"); + expect(both).toContain("### ``"); + + // A continuation restores the retained text without asking again. + calls.count = 0; + const resumed = String( + yield* run('\n', [moving], yield* continuing(stream)), + ); + expect(resumed.trim()).toBe(first.trim()); + expect(calls.count).toBe(0); + }); + it("SYN39: retains the named text, and a continuation restores it whole", function* () { const stream = new InMemoryStream(); const first = String(yield* run('\n', [], stream)); From d640fd4021ff0f330503d58a67726ad8edeeae31 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 17:05:49 -0400 Subject: [PATCH 16/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Settle=20Syntax=20te?= =?UTF-8?q?rminology=20and=20bootstrap=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SyntaxCatalog` becomes `SyntaxSymbols`, `CatalogObservation` becomes `SyntaxReference`, and the reference answers `symbols()`, `documentation()` and `available()`. The old names are removed rather than aliased: there are no users to migrate, and two names for one thing is how the two documentation lists drifted in the first place. Documentation now composes with the components it describes. Each package's bootstrap installs its registrations and its documentation in one call, through the additive `Documentation` Api; canonical core is the terminal, and every wrapper delegates before appending. Canonical execution collects once, after the trusted host's bootstrap and before the root import, and snapshots by value — so middleware a running document installs composes into a chain nothing reads, two contributions for one component refuse whichever order they were bootstrapped in, and sibling scopes stay isolated. That replaces the central `runProfileDocumentation()` list and the `ExecutionInstallation` `documentation` field, which a host had to keep in step with its registrations by hand and did not: a nested run registered `` and reported it undocumented. `xmd syntax` enters the same declarative bootstraps rather than splicing their registration arrays, so the command that has the components has the words that describe them. --- architecture.md | 74 +-- packages/cli/src/authorship-profile.ts | 26 +- packages/cli/src/cli.ts | 59 ++- packages/cli/src/plan.ts | 14 +- packages/cli/src/syntax.ts | 152 +++--- packages/cli/src/testing-host.ts | 8 - packages/cli/src/verbose-component.ts | 32 +- packages/cli/tests/plan-cli.test.ts | 8 +- .../cli/tests/plan-command-document.test.ts | 8 +- packages/cli/tests/plan-component.test.ts | 46 +- packages/cli/tests/plan.test.ts | 2 +- packages/cli/tests/run-composition.test.ts | 6 +- packages/cli/tests/support/plan-harness.ts | 61 +-- packages/cli/tests/syntax-cli.test.ts | 34 +- packages/cli/tests/verbose-component.test.ts | 4 +- packages/core/host.ts | 7 +- packages/core/mod.ts | 25 +- packages/core/src/agent/components.ts | 17 +- packages/core/src/component-documentation.ts | 61 +-- packages/core/src/components/Syntax.ts | 103 ++-- packages/core/src/components/components.md | 32 +- .../core/src/components/import-authority.ts | 4 +- packages/core/src/documentation-api.ts | 148 ++++++ packages/core/src/execute.ts | 98 ++-- packages/core/src/expand.ts | 10 +- packages/core/src/inspect.ts | 22 +- packages/core/src/invocation-identity.ts | 10 +- packages/core/src/syntax-markdown.ts | 18 +- ...tax-observation.ts => syntax-reference.ts} | 238 +++++----- packages/core/tests/syntax-catalog.test.ts | 12 +- packages/core/tests/syntax-component.test.ts | 439 +++++++++++++----- packages/testing/mod.ts | 1 + packages/testing/src/components.ts | 22 +- packages/web/mod.ts | 7 +- packages/web/src/components.ts | 32 +- .../workflow/src/composition/installation.ts | 27 +- scripts/validate-documentation.ts | 47 +- specs/executable-mdx-spec.md | 168 ++++--- specs/plan-command-spec.md | 66 +-- 39 files changed, 1304 insertions(+), 844 deletions(-) create mode 100644 packages/core/src/documentation-api.ts rename packages/core/src/{syntax-observation.ts => syntax-reference.ts} (58%) diff --git a/architecture.md b/architecture.md index c330ab07..d8a775e2 100644 --- a/architecture.md +++ b/architecture.md @@ -123,9 +123,9 @@ Existing documents and code get aligned to this section retroactively. | provider partition | one complete, independently owned agent-provider state — runtime, store, managed sessions, queues, coordinator, teardown — selected by the one installed factory at each dispatch. Production is the single-partition case of the same path; holding a partition grants work, never permission | | `JournalProvenance` | a non-operational, equality-only witness that a live publication stream descends from the exact journal backend a provider selected for one workflow run; it grants no append, read, execution, publication or reconciliation capability, and is meaningful only because the provider retains the witness it established and later requires exact equality | | result object | the value a component binds instead of failing: `{ok: true, value}` or `{ok: false, …}`, whose failure members the component declares | -| syntax catalog | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format. `xmd syntax` prints one for an environment nobody is running; canonical `` renders one for the site an element was written at, from the same construction and the same Markdown renderer | -| catalog observation | the engine-owned lexical answer to "what may a document write here", carried by value on canonical core's expansion authority beside the import authority. The execution builds one at its root from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile; a trusted canonical evaluation boundary replaces it for the subtree it evaluates. It answers with text and carries no authority: a component named in a catalog is not a component anything may run | -| run profile declarations | the component registrations a first-party package makes, held as plain values apart from the middleware, providers, activation and launchers its installer also arranges. The installer registers exactly those values and inspection reads exactly those values, so what a run installs and what the catalog reports cannot drift | +| syntax symbols | the complete, versioned description of what a document may write in one directory under one host profile: every structural construct the engine reserves, and the one implementation selection chooses for every other name. It is observation — describing an environment installs no operational state, runs nothing and journals nothing — and it is produced once per request and projected, never rediscovered per format. `xmd syntax` prints one for an environment nobody is running; canonical `` renders one for the site an element was written at, from the same construction and the same Markdown renderer | +| syntax reference | the engine-owned lexical answer to "what may a document write here", carried by value on canonical core's expansion authority beside the import authority. The execution builds one at its root from the selection inputs it captured before any installation, middleware or document code ran, or from the one set of symbols a trusted host stated for its profile; a trusted canonical evaluation boundary replaces it for the subtree it evaluates. It answers with text and carries no authority: a component the symbols name is not a component anything may run | +| run profile declarations | the component registrations a first-party package makes, held as plain values apart from the middleware, providers, activation and launchers its installer also arranges. The installer registers exactly those values and inspection reads exactly those values, so what a run installs and what the symbols report cannot drift | | origin-only | the inspectability of a component whose contract could only be learned by loading it: a repository TypeScript module, whose schemas live on its exports and whose top level would run. Such an entry carries name, category, origin and source kind, and no contract field at all — an absent contract is stated, never rendered as an empty one | | definition-owned return state | which value body a `` selects for: one ephemeral state per execution of one value root or Markdown value component. Structural directives keep the ambient one, a component invocation hides it from the invoked body, a nested value body installs its own, and caller-projected content restores the caller's. It travels down the expansion call stack as a local rather than through a context, and no exported function accepts another body's, so nothing a document can read, replace, or import acts on a live one. The first claim on it is atomic, so a second executed return fails the body rather than replacing its value, and it appends no durable event | @@ -1709,7 +1709,7 @@ still binds `{ observations: [], output: "" }`. Local Git, Git-host, issue, process, eval and exec, native-command, credential and external-write effects are outside the class. -The versioned Dir origin is a grant boundary, not a catalog decoration. The +The versioned Dir origin is a grant boundary, not a symbol decoration. The former `@executablemd/workflow/composition#Dir` identity authorized lexical placement that created nothing; it does not authorize the persistent mutation. A continuation whose retained write table names that former identity is refused @@ -3683,7 +3683,7 @@ Every other name in the execution stays the ordinary open import it has always been. **Protection is about the answer, not about power.** A protected implementation -is handed the lexical catalog observation for its site and nothing else: no +is handed the lexical syntax reference for its site and nothing else: no component definitions, no import witness, no invocation capability, no policy table, no provider and no registration handle. The body itself is kept in a table private to the copy of core that built the implementation and reached only by @@ -3693,10 +3693,10 @@ own copy — has no body here and no answer to give. **The named form is a second question.** Bare `` answers *what may I write here*. `` answers *how do I use this one*, and -the two read different inputs on purpose. The observation therefore carries a +the two read different inputs on purpose. The reference therefore carries a pair: what may **execute** at this site, which the bare form reports, and the -**enclosing authoring catalog**, which named selection reads. At a root they are -one catalog. Under a trusted evaluation boundary that narrows execution they are +**enclosing authoring symbols**, which named selection reads. At a root they are +one set. Under a trusted evaluation boundary that narrows execution they are not, and each rendered entry states whether it is available in the current evaluation — so a nested author can be told how a component works where they may not run one, without being left to infer that documentation implies authority. @@ -3724,7 +3724,7 @@ one whose components have nothing to say. The no-documentation sentence is for a **custom** component instead, which no package governs and which takes its prose from its own document's body when it has any. -**It says so in the catalog.** A protected component reports its own origin +**It says so in the symbols.** A protected component reports its own origin kind, `protected`, rather than borrowing `registered` with `reserved: true`. The two answer a reader's actual question — *could I supply this name myself?* — oppositely: a reserved registration is a host installing something under a name @@ -3732,8 +3732,8 @@ it wants kept, so it can be absent from another run, replaced by a different host, or refused when two hosts claim it, and none of that is true here. A workflow bundle member gained its own kind for the same reason: reported as a `repository` path it read as a file the reader could edit, when it is the exact -blob `sourceHash` names, fixed when the run was defined. Both are why the catalog -is version 2 rather than an addition to version 1 — a version-1 reader was +blob `sourceHash` names, fixed when the run was defined. Both are why the symbols +are version 2 rather than an addition to version 1 — a version-1 reader was promised a closed set of origins, and the honest fix adds to that set. The durable record follows the ordinary rules. Selection itself records @@ -3743,20 +3743,20 @@ and replay asks the running execution for the implementation it built. An execution that built none refuses rather than resolving the name again, because a replay that fell back to the ordinary tiers would run whatever is offered under that name today. Each occurrence then claims the identity this execution minted, -performs one `syntax_catalog` observation, and retains exactly -`{ catalog: string }`. A continuation hostile-parses that record +performs one `syntax_symbols` read, and retains exactly +`{ symbols: string }`. A continuation hostile-parses that record and hands the same text back without consulting the filesystem, the registry, the -bundle, the host or the lexical observation again; a missing, additional or +bundle, the host or the lexical reference again; a missing, additional or mistyped member is stale input rather than a component failure, and refuses before output or binding. Two authored occurrences are two identities and two -observations, and repeated reads of one binding observe nothing again. +reads, and repeated reads of one binding read nothing again. -## The syntax catalog boundary +## The syntax symbols boundary `xmd syntax` answers what a document may write here, and answering must cost nothing. The boundary that makes that true is one operation with no authority. -**One catalog, two projections.** Core produces a `SyntaxCatalog` — version 2, a +**One symbols, two projections.** Core produces a `SyntaxSymbols` — version 2, a fixed three-category tuple, entries sorted by name — and the Markdown and JSON renderers each take that value. Neither renderer discovers anything, and neither parses the other's output, so the two formats cannot describe different @@ -3787,11 +3787,11 @@ exact Markdown a host would declare to an execution is admitted here on execution's terms too, and each declared name contributes one complete entry under built-in, reporting `declared-markdown` as its source kind and the declared origin and digest as its origin. Its private closure contributes -nothing: those names are not syntax a document may write, so a catalog that -listed them would describe an environment that does not exist. +nothing: those names are not syntax a document may write, so a set of symbols +that listed them would describe an environment that does not exist. -**An execution carries one of its own, lexically.** The catalog a running -document observes travels by value on canonical core's private expansion +**An execution carries one of its own, lexically.** The symbols a running +document is shown travel by value on canonical core's private expansion authority, beside the import authority and the identity domains — not through a Context, because a context resolves by name and a name is not a secret, so a document could build one and answer for the vocabulary it is shown. @@ -3805,27 +3805,27 @@ already in hand — nothing is imported, executed, or read from a file to descri one — and reported at the canonical repository-relative path that blob has in the commit, which is where a reader of a workflow run looks for it. -A trusted host may state the catalog its profile describes instead, on the terms +A trusted host may state the symbols its profile describes instead, on the terms every other trusted-host value travels: captured by value with the rest of the installation, before any installed code exists. One execution accepts one, and two are refused rather than ordered, because ordering them would make which -profile a document observes depend on assembly order. `xmd plan` states one — a +profile a document is shown depend on assembly order. `xmd plan` states one — a Plan is a program a later `xmd run` executes, so the vocabulary the agent must be shown is that profile's rather than the authorship execution's, which searches no repository and refuses almost every capability. An ordinary run states none and -observes itself. +describes itself. Nothing is built until an occurrence asks, so a run whose document never writes `` enumerates no includes, parses no component and reads no frontmatter. Each ask builds afresh, which is what makes an occurrence's retained -catalog its own rather than a copy of whichever one ran first. +symbols its own rather than a copy of whichever one ran first. -A trusted canonical evaluation boundary may install a narrower observation for +A trusted canonical evaluation boundary may install a narrower reference for the subtree it evaluates, and leaving that subtree restores the enclosing one. It -adds nothing: the catalog it installs is the one its own admission already -selected, so an entry absent from that admission is absent from the observation. +adds nothing: the symbols it installs are the ones its own admission already +selected, so an entry absent from that admission is absent from the reference. -**Inspection is observation, never authority.** Producing a catalog installs +**Inspection is observation, never authority.** Producing symbols installs only the declarative registration layer selection needs. It does not enter `execute()`, construct a durable stream, install a Files, Service, Agent or elicitation provider, start testing, reserve the terminal, mint an invocation @@ -3841,12 +3841,12 @@ candidate order, repository override and registered fallback are therefore not restated anywhere, and a repository component that overrides a default appears once, under user-provided, with its repository origin. -**A partial catalog is never presented as a complete one.** A missing include is +**A partial set of symbols is never presented as a complete one.** A missing include is ordinary absence. An include that exists but cannot be enumerated fails the whole request: a non-directory, an unreadable tree, a symbolic link where a directory was named, and a *selection-relevant* symbolic link to anything other than a file. Traversal never follows a link to a directory, so refusing is what -stops the catalog silently omitting everything beneath one. The diagnostic names +stops the symbols silently omitting everything beneath one. The diagnostic names the configured include and the logical entry, never the resolved host path. **Relevance is the link's own logical path**, because that path is what probing @@ -3862,8 +3862,8 @@ no name reaches. **Documented forms are canonical and checked.** A declaration says which authored forms a component accepts, and the spelling is closed: omission means both, and the only arrays are `["self-closing"]`, `["paired"]` and -`["self-closing", "paired"]`. One spelling per meaning is what lets a catalog be -compared without being normalized, so an empty array, a reversed pair, a +`["self-closing", "paired"]`. One spelling per meaning is what lets two entries +be compared without being normalized, so an empty array, a reversed pair, a repeated member and a form no invocation has are refused where the declaration is made — at registration and at the identity declarations inspection reads, because a declaration refused in only one of those places is refused only when a @@ -3988,13 +3988,13 @@ Status is measured against main. | Construct | Does | Status | | --- | --- | --- | -| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-2 JSON, from one catalog. `xmd syntax Elicit` names one component instead and renders its catalog metadata followed by the long-form documentation the owning package ships, through the same selection, index and renderer `` uses. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same catalog for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | -| `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, no props, and a text component: the bare form emits the catalog and the ordinary `as` captures the same text and emits nothing, while a paired spelling or an authored prop refuses before any observation. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the catalog says is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one catalog a trusted host stated for its profile — and it is carried lexically on canonical core's expansion authority rather than through any context. Each occurrence claims the identity the execution minted, performs one `syntax_catalog` observation, and retains exactly `{ catalog: string }`; a continuation hostile-parses that record and restores the catalog the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled observation completes its teardown and commits nothing. It reports itself under its own catalog origin kind, `protected`, never as a reserved registration, and selection records `{ kind: "protected" }` alone — replay asks this execution for the implementation it built rather than resolving the name again. It carries no authority at all: a component named in a catalog is neither registered, resolved nor authorized by being named | built on this stack; the narrower observation a trusted evaluation boundary installs for its subtree is the seam #713 fills | +| `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-2 JSON, from one construction. `xmd syntax Elicit` names one component instead and renders its symbol metadata followed by the long-form documentation the owning package ships, through the same selection, index and renderer `` uses. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same symbols for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | +| `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, with one optional closed `names` prop, and a text component: the bare form lists the symbols available here, `` renders those components' metadata and the long-form documentation their owning package ships, and the ordinary `as` captures the same text and emits nothing — while a paired spelling, an unknown prop, an empty list, a duplicate or a non-string member refuses before anything is claimed or read. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the symbols say is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one set of symbols a trusted host stated for its profile — and they are carried lexically on canonical core's expansion authority rather than through any context. The documentation the named form reads is collected the same way: each package's bootstrap contributes its own through the additive `Documentation` Api, canonical core is the terminal, and the execution collects once after the trusted host's bootstrap and snapshots by value before the root import, so middleware a running document installs composes into a chain nothing reads and two contributions for one component of one package refuse whichever order they were bootstrapped in. Each occurrence claims the identity the execution minted, performs one `syntax_symbols` read, and retains exactly `{ symbols: string }`; a continuation hostile-parses that record and restores the text the run actually showed without rediscovering a moved environment, while a missing, additional or mistyped member is stale input that refuses before output or binding. A cancelled read completes its teardown and commits nothing. It reports itself under its own origin kind, `protected`, never as a reserved registration, and selection records `{ kind: "protected" }` alone — replay asks this execution for the implementation it built rather than resolving the name again. It carries no authority at all: a component the symbols name is neither registered, resolved nor authorized by being named | built on this stack; the narrower reference a trusted evaluation boundary installs for its subtree is the seam #713 fills | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft and every failed check's structured findings — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | -| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | +| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any inspection, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | | `` / `` | chooses one branch by comparing a value with `===`. `` decides its whole case structure from source before evaluating anything, then evaluates the selector once and each non-default matcher at most once in source order, expands the first `===` match — or the final default, or nothing — inline and transparently, and appends no journal event | built on the #692 stack | -| `xmd upgrade` | replaces the standalone binary that ran it with a published release, by executing one root document: the packaged upgrade command document, under the internal `` identity, with an empty component search path and no Files, Process, Service, command, Fetch, Agent, Elicitation, workflow or repository capability. That document is an **ordinary streaming text root** — it declares no `returns` and uses neither `` nor `` — so its rendered body is the command's output: each root segment reaches the reader as it completes, and a branch the command did not take contributes no prose, no phase call and no result. Its durable events go to one invocation-local in-memory stream, or to the file `--journal` named and the CLI exclusively created; neither is ever read back, and neither grants any resume or retry authority. Markdown owns the whole of the policy — the exact-tag grammar, which release is selected, semantic-version comparison through the npm `semver` package, which consent an install needs, the status, already-current and installation branches, and the wording of every refusal and every report. A compiled macOS or Linux binary whose platform the release publishes for is the only host that declares the four phases that policy may reach, ``, ``, `` and ``, and it declares them to canonical execution rather than through any contextual Api, middleware, repository lookup, ordinary `xmd run` profile or public syntax catalog; every other entrypoint states its provenance and no authority at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and stops at its own refusal before release lookup or any filesystem change. That host alone owns the private half: the exact `process.execPath` spelling it will replace and never a link it resolved, one non-blocking exclusive advisory lock on a stable sidecar beside that file, the bounded anonymous GitHub reads under a scope-bound abort signal, the downloaded bytes, the digest, the staged candidate it runs for its version, and one same-directory rename. Opaque identity is the boundary between the two halves — a release identity per admitted release, then one candidate advancing `downloaded → verified → committed` exactly once, with one installation attempt per invocation — so the document chooses among the releases it was shown and can name no other release, target, asset or destination, skip verification or replay a phase. Before the rename every failure and cancellation leaves the installed file byte-identical; after it the candidate is authoritative and no cleanup restores the old bytes | built on the #659 stack | +| `xmd upgrade` | replaces the standalone binary that ran it with a published release, by executing one root document: the packaged upgrade command document, under the internal `` identity, with an empty component search path and no Files, Process, Service, command, Fetch, Agent, Elicitation, workflow or repository capability. That document is an **ordinary streaming text root** — it declares no `returns` and uses neither `` nor `` — so its rendered body is the command's output: each root segment reaches the reader as it completes, and a branch the command did not take contributes no prose, no phase call and no result. Its durable events go to one invocation-local in-memory stream, or to the file `--journal` named and the CLI exclusively created; neither is ever read back, and neither grants any resume or retry authority. Markdown owns the whole of the policy — the exact-tag grammar, which release is selected, semantic-version comparison through the npm `semver` package, which consent an install needs, the status, already-current and installation branches, and the wording of every refusal and every report. A compiled macOS or Linux binary whose platform the release publishes for is the only host that declares the four phases that policy may reach, ``, ``, `` and ``, and it declares them to canonical execution rather than through any contextual Api, middleware, repository lookup, ordinary `xmd run` profile or public syntax symbols; every other entrypoint states its provenance and no authority at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and stops at its own refusal before release lookup or any filesystem change. That host alone owns the private half: the exact `process.execPath` spelling it will replace and never a link it resolved, one non-blocking exclusive advisory lock on a stable sidecar beside that file, the bounded anonymous GitHub reads under a scope-bound abort signal, the downloaded bytes, the digest, the staged candidate it runs for its version, and one same-directory rename. Opaque identity is the boundary between the two halves — a release identity per admitted release, then one candidate advancing `downloaded → verified → committed` exactly once, with one installation attempt per invocation — so the document chooses among the releases it was shown and can name no other release, target, asset or destination, skip verification or replay a phase. Before the rename every failure and cancellation leaves the installed file byte-identical; after it the candidate is authoritative and no cleanup restores the old bytes | built on the #659 stack | | `` / `printErrors(fn)` | prints failures | built on main | | `` | stops authored work with the sentence its author wrote, raised where it is written. An ordinary overridable core default — never structural, never reserved, so a repository `Fail.md` is chosen ahead of it — with a closed schema of one required non-empty `message` and **self-closing only**: a paired spelling never enters its body, and `as` is refused by the body itself because there is nothing to bind. Every refusal reports the invocation and happens before the authored message, so a document that never reached its decision is never reported as having made one. A valid invocation is the ordinary failure of a function component: an `Error` carrying the exact authored message, positioned at the opening tag, rendering nothing and binding nothing. It carries no `printErrors()` declaration, which is what leaves recovery to an authored `` region under ordinary text-root modes; a value body's `throw` is not replaced there, so the authored failure settles the body ahead of missing-`` settlement. No authority, context, provider, resource, module state or durable operation of its own: replay of a completed root restores the recorded outcome without re-expanding the body | built on the #659 stack | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index 9a682eff..f67d2649 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -45,7 +45,7 @@ import { useTerminalOutput, } from "@executablemd/core"; import type { AgentProviderOptions, Json } from "@executablemd/core"; -import type { CatalogContribution, DeclaredMarkdownComponent } from "@executablemd/core/host"; +import type { SyntaxSymbolsProvider, DeclaredMarkdownComponent } from "@executablemd/core/host"; import { executeInstalled, installInvocationAgentProvider } from "@executablemd/core/host"; import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; @@ -169,15 +169,15 @@ export interface AuthorshipProfile { */ declaration: DeclaredMarkdownComponent; /** - * The catalog this authorship describes. + * The symbols this authorship describes. * * The `run` profile's, because a Plan is a program a later `xmd run` executes: - * deriving one from this execution — which searches no repository and refuses - * almost every capability — would describe a vocabulary the approved program - * would not have. Captured with the rest of the installation, before any - * installed code, middleware or document code runs. + * deriving them from this execution — which searches no repository and + * refuses almost every capability — would describe a vocabulary the approved + * program would not have. Captured with the rest of the installation, before + * any installed code, middleware or document code runs. */ - catalog: CatalogContribution; + symbols: SyntaxSymbolsProvider; } /** What building the constrained provider needs, and nothing more. */ @@ -236,7 +236,7 @@ export interface PlanAuthorshipPolicy { * * Not a description built beside the installation but the installation itself: * the same value registers the provider, installs the invocation options, and - * is handed to a trusted host as its observation. There is nothing for a report + * is handed to a trusted host as its reference. There is nothing for a report * to disagree with, because there is no second report. */ export interface PlanProviderAssembly { @@ -285,7 +285,7 @@ export function planAgentContext( defaultAgent: stack.defaultAgent, *installProvider(invocation: PlanAuthorshipInvocation): Operation { // One assembly, used for every installation and handed back as the - // observation. Nothing is reconstructed afterward, so a report cannot + // reference. Nothing is reconstructed afterward, so a report cannot // describe an arrangement other than the one installed. const installed: PlanProviderAssembly = { provider: "acpx", @@ -329,7 +329,7 @@ export interface AuthorshipFrame { readonly session: string; /** The exact authored label a trusted child host may address privately. */ readonly authoredSession?: string; - observe?(observation: PlanAuthorshipObservation): Operation; + observe?(reference: PlanAuthorshipObservation): Operation; installElicitation(): Operation; } @@ -468,11 +468,11 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation` is registered at all. yield* Config.around({ verbose: () => verbose }, { at: "min" }); - // The repository-composition vocabulary, as ordinary shadowable defaults. - // Registering it installs no provider, discovers no repository, acquires no - // lock and reaches no network: what a name *does* is decided by whichever - // provider the command installed, and a runtime that installs none still - // resolves every one of these. - yield* registerComponents(COMPOSITION_REGISTRATIONS); + // The repository-composition vocabulary, as ordinary shadowable defaults, + // with the documentation that describes it. Bootstrapping it installs no + // provider, discovers no repository, acquires no lock and reaches no network: + // what a name *does* is decided by whichever provider the command installed, + // and a runtime that installs none still resolves every one of these. + yield* useCompositionComponents(); // Compose testing around the single core execution entrypoint: both // commands register the components (assertions work in regular documents, @@ -940,7 +938,7 @@ export function* installDocumentComponents(mode: DocumentMode, verbose: boolean) yield* installTestAgentComponents(); yield* installAgentComponents(); } else { - yield* registerComponents([VERBOSE_REGISTRATION]); + yield* useVerboseComponent(); yield* installTestingComponents({ verbose }); } @@ -1172,12 +1170,6 @@ function* runDocument( // and does not gain `` at its root — but the production run child // it can launch is the run profile, and gets it below. ...(mode.testing ? {} : { declarations: [plan] }), - // The documentation the packages this profile registers ship, beside - // the registrations themselves. Without it a document's own - // `` would read a core-only index and answer with the - // fallback sentence for a component `xmd syntax NAME` documents fully — - // one product, two answers. - documentation: yield* runProfileDocumentation(), }, // The declarations a nested execution may configure a child with, named // by the exact definitions this command installed. Recognizing one is @@ -2524,7 +2516,7 @@ function* dispatch( }, { ...(sessions === undefined ? {} : { sessions }), - catalog: syntaxCatalog, + symbols: syntaxSymbols, // The two facts about this process's own stderr that nothing further // in may go and read: whether it is a terminal, and whether it took // what it was handed. The approved Plan's sinks are stdout and @@ -2648,21 +2640,22 @@ function* dispatch( // the catalog would read as complete. let rendered: string; try { - const catalog = yield* syntaxCatalog(command.config.include); const named = command.config.component; - rendered = - named === undefined - ? // The compact catalog, unchanged: routine discovery output and every - // default Plan prompt read it, and long documentation would make both - // unnecessarily large. - command.config.json - ? renderSyntaxJson(catalog) - : renderSyntaxMarkdown(catalog) - : // The same selection, index and renderer `` uses, so - // the command and the component cannot describe one component two - // ways. JSON stays the compact projection; it is the catalog's shape, - // and documentation is prose rather than a catalog member. - yield* renderSyntaxDocumentation(catalog, [named]); + if (named === undefined) { + // The compact list of symbols, unchanged: routine discovery output and + // every default Plan prompt read it, and long documentation would make + // both unnecessarily large. + const catalog = yield* syntaxSymbols(command.config.include); + rendered = command.config.json + ? renderSyntaxJson(catalog) + : renderSyntaxMarkdown(catalog); + } else { + // The same selection, index and renderer `` uses, so + // the command and the component cannot describe one component two + // ways. JSON stays the compact projection; it is the symbols' shape, + // and documentation is prose rather than a symbol member. + rendered = yield* renderSyntaxDocumentation(command.config.include, [named]); + } } catch (error) { console.error(describeError(error)); yield* exit(1); diff --git a/packages/cli/src/plan.ts b/packages/cli/src/plan.ts index 1e4791e1..c91cd43a 100644 --- a/packages/cli/src/plan.ts +++ b/packages/cli/src/plan.ts @@ -40,7 +40,7 @@ import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import process from "node:process"; -import type { SyntaxCatalog } from "@executablemd/core"; +import type { SyntaxSymbols } from "@executablemd/core"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableStream } from "@executablemd/durable-streams"; @@ -98,7 +98,7 @@ export interface PlanDependencies { /** What the profile's provider is built on, beyond the host's own assembly. */ acp?: AcpxProviderDependencies; /** The run profile's complete structured vocabulary. */ - catalog(includes: readonly string[]): Operation; + symbols(includes: readonly string[]): Operation; /** Who answers the review question. */ installElicitation(): Operation; /** @@ -217,11 +217,11 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio progress: deps.progress, // The vocabulary this authorship describes is the `run` profile's, not // this execution's: a Plan is a program a later `xmd run` executes, so the - // catalog the Agent must be shown is the one that run will have. Stated at - // the execution boundary and captured before any installed code — no prop - // on the thin adapter, and nothing the Component projects, could supply - // another. - catalog: () => deps.catalog(command.include), + // symbols the Agent must be shown are the ones that run will have. Stated + // at the execution boundary and captured before any installed code — no + // prop on the thin adapter, and nothing the Component projects, could + // supply another. + symbols: () => deps.symbols(command.include), }); } catch (error) { console.error(describeError(error)); diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 6ab34664..3c5eb397 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -2,15 +2,21 @@ * `xmd syntax` — everything a document may write here, described without * running any of it. * - * Two jobs, kept apart. The first is assembling the `run` host profile as - * *declarations*: the same arrays the runtime installers register, with none of + * Two jobs, kept apart. The first is entering the `run` profile's *declarative* + * bootstraps: the same calls the runtime installers delegate to, with none of * the middleware, providers, launchers or activation those installers also - * arrange. The second is rendering, and both renderers take the catalog as a + * arrange. The second is rendering, and both renderers take the symbols as a * value — neither performs discovery, and neither parses the other's output. * + * Entering the bootstraps rather than splicing their registration arrays is + * what makes the documentation this command reads the profile's own: a package + * installs its registrations and its documentation in one call, so a command + * that has the components has the words that describe them. Splicing the arrays + * left the two halves to be kept in step by hand, and they were not. + * * JSON is the canonical, lossless projection and belongs to this command. * Markdown belongs to core, because a document that writes `` is shown - * the same catalog in the same words: two renderings that agreed only by hand + * the same symbols in the same words: two renderings that agreed only by hand * would be one release away from telling an operator and an agent different * things about one profile. */ @@ -19,114 +25,92 @@ import { planComponentDescription } from "./plan-component.ts"; import { scoped } from "effection"; import type { Operation } from "effection"; import { - AGENT_REGISTRATIONS, - agentDocumentation, agentIdentityComponents, + capturedDocumentation, documentationIndexFor, inspectSyntax, - registerComponents, renderSelectedDocumentation, renderSyntaxMarkdown, selectDocumented, + useAgentComponents, } from "@executablemd/core"; -import type { DocumentationContribution, SyntaxCatalog } from "@executablemd/core"; -import { TESTING_REGISTRATIONS, testingDocumentation } from "@executablemd/testing"; -import { WEB_REGISTRATIONS, webDocumentation } from "@executablemd/web"; -import { cliDocumentation, VERBOSE_REGISTRATION } from "./verbose-component.ts"; -import { COMPOSITION_REGISTRATIONS, compositionDocumentation } from "@executablemd/workflow"; +import type { SyntaxSymbols } from "@executablemd/core"; +import { useTestingComponents } from "@executablemd/testing"; +import { useWebComponents } from "@executablemd/web"; +import { useVerboseComponent } from "./verbose-component.ts"; +import { useCompositionComponents } from "@executablemd/workflow"; export { renderSyntaxMarkdown }; /** - * The catalog for the production `run` profile, in the contextual working + * The symbols for the production `run` profile, in the contextual working * directory. * - * The registrations are the ones `installTestingComponents()`, + * The declarations are the ones `installTestingComponents()`, * `installWebComponents()`, `installAgentComponents()` and the - * repository-composition installer register, read as values so this cannot - * drift from what a run installs. What those installers - * *also* do — testing activation and its execution middleware, the elicitation - * provider, the agent provider, the permission mode, the foreground launcher — - * is operational and belongs to a run, so none of it happens here. + * repository-composition installer each delegate to, entered here directly so + * this cannot drift from what a run installs. What those installers *also* do — + * testing activation and its execution middleware, the elicitation provider, + * the agent provider, the permission mode, the foreground launcher — is + * operational and belongs to a run, so none of it happens here. * * `` travels as a declaration for the same reason: its factory takes * an execution's claimant, and describing an environment mints no execution. * * The scope is bounded, and everything installed in it is declarative registry - * state. Leaving it removes the layer, and there is no process, agent, service, - * journal, file or authority left to clean up. + * state and documentation middleware. Leaving it removes the layer, and there + * is no process, agent, service, journal, file or authority left to clean up. */ -export function* syntaxCatalog(includes: readonly string[]): Operation { +export function* syntaxSymbols(includes: readonly string[]): Operation { return yield* scoped(function* () { yield* useRunProfileRegistry(); - return yield* inspectSyntax({ - includes, - components: agentIdentityComponents(), - // `` is part of the run profile, so a catalog that left it out would - // describe a vocabulary no run has. Described from the packaged bytes: - // inspection mints nothing, so it reports the Component's identity and - // contract without building the capabilities only a run can build. - declarations: [yield* planComponentDescription()], - }); + return yield* profileSymbols(includes); + }); +} + +/** The profile's symbols, inside a scope that has already bootstrapped it. */ +function* profileSymbols(includes: readonly string[]): Operation { + return yield* inspectSyntax({ + includes, + components: agentIdentityComponents(), + // `` is part of the run profile, so symbols that left it out would + // describe a vocabulary no run has. Described from the packaged bytes: + // inspection mints nothing, so it reports the Component's identity and + // contract without building the capabilities only a run can build. + declarations: [yield* planComponentDescription()], }); } /** - * The registrations the `run` profile installs, as registry state and nothing - * else. + * The declarations the `run` profile installs, and nothing else. * * Shared with `xmd plan`, which both describes this vocabulary to a generator - * and validates what comes back. Registering only here would make the catalog + * and validates what comes back. Bootstrapping only here would make the symbols * advertise `` while validation reported it unresolved — a document told * to use a component nobody would accept. - */ -export function* useRunProfileRegistry(): Operation { - yield* registerComponents([ - VERBOSE_REGISTRATION, - ...AGENT_REGISTRATIONS, - ...TESTING_REGISTRATIONS, - ...WEB_REGISTRATIONS, - // The repository-composition vocabulary. Registering it is all that happens - // here: catalog construction installs no provider, discovers no ambient - // repository, acquires no lock, spawns no Git and reads no credential. - ...COMPOSITION_REGISTRATIONS, - ]); -} - -/** - * The documentation the `run` profile's own packages contribute. - * - * Assembled beside `useRunProfileRegistry()` and from the same declarations, so - * a package whose components this profile registers is a package whose - * documentation this profile demands. Core's own is added by - * `documentationIndexFor()`; everything here is a boundary outside it. * - * The list is deliberately not "whatever is installed": it is captured at the - * trusted boundary, before any document code exists, so nothing a running - * document reaches can add a source, remove one, or answer for what a component - * does. + * Each call is one package's declarative bootstrap: its registrations and the + * documentation that describes them. None of them installs a provider, + * discovers an ambient repository, acquires a lock, spawns Git or reads a + * credential. */ -export function* runProfileDocumentation(): Operation { - // One entry per boundary `useRunProfileRegistry()` installs, in the same - // order and from the same declarations. Core's own is added by - // `documentationIndexFor()`; these are the boundaries outside it. - return [ - yield* agentDocumentation(), - yield* cliDocumentation(), - yield* testingDocumentation(), - yield* webDocumentation(), - yield* compositionDocumentation(), - ]; +export function* useRunProfileRegistry(): Operation { + yield* useVerboseComponent(); + yield* useAgentComponents(); + yield* useTestingComponents(); + yield* useWebComponents(); + // The repository-composition vocabulary. + yield* useCompositionComponents(); } /** - * The catalog as JSON: two-space indent, one trailing newline. + * The symbols as JSON: two-space indent, one trailing newline. * - * Catalog construction owns member insertion order, category order and entry - * order, so the bytes are the same for the same environment. + * Construction owns member insertion order, category order and entry order, so + * the bytes are the same for the same environment. */ -export function renderSyntaxJson(catalog: SyntaxCatalog): string { - return `${JSON.stringify(catalog, null, 2)}\n`; +export function renderSyntaxJson(symbols: SyntaxSymbols): string { + return `${JSON.stringify(symbols, null, 2)}\n`; } /** @@ -137,13 +121,23 @@ export function renderSyntaxJson(catalog: SyntaxCatalog): string { * agent reading a document are answering the same question, and two renderings * that agreed only by hand would be one release away from disagreeing. * - * Nothing here narrows execution, so every entry a catalog holds is available + * The symbols and the index come from *one* entry into the profile's + * bootstraps, inside this scope. Building them from two entries would let the + * command describe a component from one assembly and document it from another; + * building the index outside the scope would find no contribution at all, since + * a bootstrap's documentation belongs to the scope that entered it. + * + * Nothing here narrows execution, so every entry the symbols hold is available * and each says so. */ export function* renderSyntaxDocumentation( - catalog: SyntaxCatalog, + includes: readonly string[], names: readonly string[], ): Operation { - const index = yield* documentationIndexFor(yield* runProfileDocumentation()); - return renderSelectedDocumentation(selectDocumented(catalog, catalog, names, index)); + return yield* scoped(function* () { + yield* useRunProfileRegistry(); + const catalog = yield* profileSymbols(includes); + const index = documentationIndexFor(yield* capturedDocumentation()); + return renderSelectedDocumentation(selectDocumented(catalog, catalog, names, index)); + }); } diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index b2c5c15c..526daacf 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -58,7 +58,6 @@ import type { TestAgentChildConfiguration, } from "@executablemd/testing"; import { installDocumentComponents } from "./cli.ts"; -import { runProfileDocumentation } from "./syntax.ts"; import type { HostServiceInstaller } from "./cli.ts"; import type { RepositoryInstaller } from "./run-repositories.ts"; @@ -330,13 +329,6 @@ function* runProfileChild( : { observeAuthorship: settings.observePlanAuthorship }), }), ], - // The documentation for the same profile's registrations, beside the - // declarations rather than anywhere else. A child that registered the run - // profile's components without their documentation would answer - // `` with the no-documentation sentence — a - // component it can run, described as undocumented — because the index it - // built would hold core's contributions alone. - documentation: yield* runProfileDocumentation(), }); // A child gets what `xmd run` gets, and the browser form is part of that. // Installed here rather than inherited: this scope is isolated from the diff --git a/packages/cli/src/verbose-component.ts b/packages/cli/src/verbose-component.ts index 2d52d22d..4c054c22 100644 --- a/packages/cli/src/verbose-component.ts +++ b/packages/cli/src/verbose-component.ts @@ -14,8 +14,19 @@ * observed. */ -import { content, packageDocumentation, verbose } from "@executablemd/core"; -import type { ComponentRegistration, DocumentationContribution, Json } from "@executablemd/core"; +import { + content, + contributeDocumentation, + packageDocumentation, + registerComponents, + verbose, +} from "@executablemd/core"; +import type { + ComponentRegistration, + DocumentationContribution, + DocumentationReader, + Json, +} from "@executablemd/core"; import type { Operation } from "effection"; export const VERBOSE_ORIGIN = "@executablemd/cli"; @@ -34,14 +45,29 @@ function* Verbose(_props: Record): Operation { } /** This command's long-form documentation, derived from what it registers. */ -export function* cliDocumentation(): Operation { +export function* cliDocumentation( + read?: DocumentationReader, +): Operation { return yield* packageDocumentation( new URL("./components.md", import.meta.url), { owner: VERBOSE_ORIGIN, asset: "packages/cli/src/components.md" }, [VERBOSE_REGISTRATION.name], + read, ); } +/** + * This command's own vocabulary, as declarations and nothing else. + * + * One registration and the documentation that describes it, installed together + * so a scope that has one has the other — the same call `xmd syntax` enters to + * describe the run profile. + */ +export function* useVerboseComponent(): Operation { + yield* registerComponents([VERBOSE_REGISTRATION]); + yield* contributeDocumentation(cliDocumentation); +} + /** The one declaration the run profile registers and `xmd syntax` describes. */ export const VERBOSE_REGISTRATION: ComponentRegistration = { name: "Verbose", diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index 0e43c323..47392c15 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -174,7 +174,7 @@ function complaints(stderr: string): string { /** Every phase after a refusal, at zero. */ function untouched(harness: PlanHarness): Record { return { - catalogs: harness.catalogCalls.length, + catalogs: harness.symbolCalls.length, runtimes: harness.fake.created.length, started: harness.fake.started, turns: harness.fake.prompts.length, @@ -1699,8 +1699,8 @@ describe( // observes is that Preparing reached the operator before the catalog // was read rather than merely that both happened. const before: string[][] = []; - const catalog = harness.deps.catalog; - harness.deps.catalog = function* (includes) { + const catalog = harness.deps.symbols; + harness.deps.symbols = function* (includes) { before.push(phasesOf(harness.progress.join(""))); return yield* catalog(includes); }; @@ -1708,7 +1708,7 @@ describe( const { value } = yield* delivered(() => runPlan(planning(dir), harness.deps)); expect(value).toBe(0); - expect(harness.catalogCalls).toEqual([[dir]]); + expect(harness.symbolCalls).toEqual([[dir]]); expect(before).toEqual([["Preparing the Plan"]]); }); }); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 8d953424..eeee655f 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -37,7 +37,7 @@ import type { DocumentValidation, ElicitationRequest, Json, - SyntaxCatalog, + SyntaxSymbols, } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -146,7 +146,7 @@ function* runDocument(options: RunOptions = {}): Operation { // *when* the public `` occurrence observed it — which is what an // ordering case about the authored Preparing phase is asking. // deno-lint-ignore require-yield - *catalog(): Operation { + *symbols(): Operation { events.push("catalog"); return CASE_CATALOG; }, @@ -187,7 +187,7 @@ function* runDocument(options: RunOptions = {}): Operation { { components: agentIdentityComponents(), declarations: [harness.declaration], - catalog: harness.catalog, + symbols: harness.symbols, }, ], ); @@ -504,7 +504,7 @@ describe("the packaged plan command document", () => { { components: agentIdentityComponents(), declarations: [installed], - catalog: harness.catalog, + symbols: harness.symbols, }, ], ); diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 9b101f87..d9982d0e 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -30,7 +30,7 @@ import { retainedSource, useNormalizedOutput, } from "@executablemd/core"; -import type { Json, SyntaxCatalog } from "@executablemd/core"; +import type { Json, SyntaxSymbols } from "@executablemd/core"; import { validateDocument } from "@executablemd/core"; import { executeInstalled, sourceDigest } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -49,7 +49,7 @@ import { structuralValidation, } from "../src/plan-component.ts"; import type { StructuralValidation } from "../src/plan-component.ts"; -import { syntaxCatalog } from "../src/syntax.ts"; +import { syntaxSymbols } from "../src/syntax.ts"; import { PLAN_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; const ROOT = "document.md"; @@ -157,7 +157,7 @@ function* runDocument(options: { // Where the profile a document observes is settled now: the // `` the packaged Plan writes is canonical core's public // component, and what it answers with is this execution's. - catalog: harness.catalog, + symbols: harness.symbols, }, ], ); @@ -263,17 +263,17 @@ describe("Tier PC — in an ordinary document", () => { const first = run.harness.fake.prompts[0] ?? ""; expect(first).toContain("### ``"); expect(first.split("### ``").length - 1).toBe(1); - expect(run.harness.catalogCalls).toBe(1); + expect(run.harness.symbolCalls).toBe(1); }); }); - it("PC1c: a catalog observation that fails reaches no session, turn, review or Plan", function* () { + it("PC1c: a catalog reference that fails reaches no session, turn, review or Plan", function* () { yield* useWorkingDirectory(function* (dir) { const harness = yield* planDeclarationHarness({ surface: "component", authorshipRoot: `${dir}-profile`, // deno-lint-ignore require-yield - *catalog(): Operation { + *symbols(): Operation { throw new Error("the profile could not be described"); }, }); @@ -287,7 +287,7 @@ describe("Tier PC — in an ordinary document", () => { }); expect(run.failure).toContain("the profile could not be described"); - // Nothing downstream of the observation happened: no turn was taken, no + // Nothing downstream of the reference happened: no turn was taken, no // review was asked, no draft was checked, and no Plan was bound. expect(run.harness.fake.prompts).toEqual([]); expect(run.harness.reviews).toEqual([]); @@ -404,7 +404,7 @@ describe("Tier PC — in an ordinary document", () => { it("PC7: the catalog advertises and none of its private names", function* () { yield* useWorkingDirectory(function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const builtIn = catalog.categories[1].entries; const plan = builtIn.find((entry) => entry.name === "Plan"); @@ -447,7 +447,7 @@ describe("Tier PC — in an ordinary document", () => { // unresolved, and never runs. yield* writeTextFile(join(dir, "CheckDraft.md"), "the repository file ran.\n"); - const catalog = yield* syntaxCatalog([dir]); + const catalog = yield* syntaxSymbols([dir]); for (const category of catalog.categories) { expect(category.entries.map((entry) => entry.name)).not.toContain("CheckDraft"); } @@ -524,7 +524,7 @@ describe("Tier PC — in an ordinary document", () => { harness: yield* planDeclarationHarness({ surface: "component", authorshipRoot: yield* authorshipRoot(), - *catalog() { + *symbols() { catalogs += 1; throw new Error("a restored syntax snapshot was rebuilt"); }, @@ -1103,33 +1103,33 @@ describe("Tier PC — in an ordinary document", () => { }); }); - it("PC27: the Plan's catalog observation is core's closed record, and a hostile one produces nothing", function* () { + it("PC27: the Plan's syntax record is core's closed record, and a hostile one produces nothing", function* () { yield* useWorkingDirectory(function* () { const approved = yield* approvedRun(); // The record is canonical core's, not Plan's: the packaged Component // writes the same public `` any document writes, so what a - // continuation restores is a `syntax_catalog` observation rather than + // continuation restores is a `syntax_symbols` reference rather than // anything this host retained. - const observation = (yield* approved.readAll()).find( - (event) => event.type === "yield" && event.description.type === "syntax_catalog", + const reference = (yield* approved.readAll()).find( + (event) => event.type === "yield" && event.description.type === "syntax_symbols", ); - expect(observation?.type).toBe("yield"); - if (observation?.type !== "yield" || observation.result.status !== "ok") { - throw new Error("the approved run retained no catalog observation"); + expect(reference?.type).toBe("yield"); + if (reference?.type !== "yield" || reference.result.status !== "ok") { + throw new Error("the approved run retained no syntax record"); } - const value = Object(observation.result.value); - expect(Object.keys(value)).toEqual(["catalog"]); - expect(typeof value.catalog).toBe("string"); + const value = Object(reference.result.value); + expect(Object.keys(value)).toEqual(["symbols"]); + expect(typeof value.symbols).toBe("string"); const cases: [string, (value: Json) => Json][] = [ ["the member is missing", () => ({})], ["an unknown member was added", (record) => ({ ...Object(record), extra: true })], - ["the member has the wrong type", () => ({ catalog: 7 })], + ["the member has the wrong type", () => ({ symbols: 7 })], ]; for (const [, replace] of cases) { - const run = yield* continued(yield* tampered(approved, "syntax_catalog:", replace)); - expect(run.failure).toContain("retained catalog is not a catalog"); + const run = yield* continued(yield* tampered(approved, "syntax_symbols:", replace)); + expect(run.failure).toContain("retained text is not a record"); expect(run.output).not.toContain("got:"); expect(run.output).not.toContain("# Say hello"); expect(run.harness.fake.prompts).toEqual([]); diff --git a/packages/cli/tests/plan.test.ts b/packages/cli/tests/plan.test.ts index 8e7689dc..d9982278 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -280,7 +280,7 @@ describe( expect(code).toBe(0); // Exactly one catalog, built with the invocation's own includes. - expect(harness.catalogCalls).toEqual([[dir]]); + expect(harness.symbolCalls).toEqual([[dir]]); // The turn is the shipped Markdown's, word for word: the sentences below // exist nowhere in TypeScript, so a host that wrote its own workflow could diff --git a/packages/cli/tests/run-composition.test.ts b/packages/cli/tests/run-composition.test.ts index a7da7254..0368e313 100644 --- a/packages/cli/tests/run-composition.test.ts +++ b/packages/cli/tests/run-composition.test.ts @@ -27,7 +27,7 @@ import { exists, readdir, readTextFile, writeTextFile } from "@effectionx/fs"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { join } from "node:path"; import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; -import { syntaxCatalog, useRunProfileRegistry } from "../src/syntax.ts"; +import { syntaxSymbols, useRunProfileRegistry } from "../src/syntax.ts"; import { DEFAULT_REPOSITORY_ROOT, unsupportedRepositories } from "../src/run-repositories.ts"; /** Every element an author can write that needs a repository provider. */ @@ -142,7 +142,7 @@ describe("ORC1 — describing the vocabulary reaches nothing", () => { }, { at: "min" }, ); - return yield* syntaxCatalog([]); + return yield* syntaxSymbols([]); }); // The whole vocabulary is described. @@ -183,7 +183,7 @@ describe("ORC2 — one language, described everywhere and operated somewhere", ( ); // And the catalog every runtime builds describes each of them completely. - const catalog = yield* scoped(() => syntaxCatalog([])); + const catalog = yield* scoped(() => syntaxSymbols([])); const builtIn = catalog.categories[1].entries; for (const name of COMPOSITION_NAMES) { const entry = builtIn.find((candidate) => candidate.name === name); diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index ac5f8fc8..3435f241 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -3,7 +3,7 @@ * * Every phase the command owns is driven in process: the ACPX runtime is the * scriptable fake, the review provider is a scripted `Elicitation` handler, the - * catalog and the execution are recorded, and the contextual working directory + * symbols and the execution are recorded, and the contextual working directory * is a temporary one. No live agent, browser, or network belongs in this * evidence. * @@ -13,7 +13,7 @@ */ import { Elicitation } from "@executablemd/core"; -import type { DocumentValidation, ElicitationRequest, SyntaxCatalog } from "@executablemd/core"; +import type { DocumentValidation, ElicitationRequest, SyntaxSymbols } from "@executablemd/core"; import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import { ensure, scoped, useScope } from "effection"; @@ -24,12 +24,12 @@ import { join } from "node:path"; import { API, useHostFiles } from "@executablemd/runtime"; import { createEmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; -import { syntaxCatalog } from "../../src/syntax.ts"; +import { syntaxSymbols } from "../../src/syntax.ts"; import { planComponentDeclaration } from "../../src/plan-component.ts"; import type { PlanSurface, StructuralValidation } from "../../src/plan-component.ts"; import { planAgentContext } from "../../src/authorship-profile.ts"; import type { AuthorshipStack } from "../../src/agent-stack.ts"; -import type { CatalogContribution, DeclaredMarkdownComponent } from "@executablemd/core/host"; +import type { SyntaxSymbolsProvider, DeclaredMarkdownComponent } from "@executablemd/core/host"; import type { PlanDependencies } from "../../src/plan.ts"; import { createFakeAcp, makeRegistry, makeStore } from "./fake-acp.ts"; import type { FakeAcp, FakeStore } from "./fake-acp.ts"; @@ -70,8 +70,8 @@ export interface ScriptedReview { export interface PlanHarness { fake: FakeAcp; - /** Every catalog request, by the includes it was made with. */ - catalogCalls: string[][]; + /** Every symbols request, by the includes it was made with. */ + symbolCalls: string[][]; /** Every review request a provider was asked, in order. */ reviews: ElicitationRequest[]; /** @@ -98,8 +98,8 @@ export function createPlanHarness(options: { * whose was whose. */ authorshipRoot: string; - /** Replace the catalog entirely, for a case about catalog failure. */ - catalog?: (includes: readonly string[]) => Operation; + /** Replace the symbols entirely, for a case about their failure. */ + symbols?: (includes: readonly string[]) => Operation; /** * The ACPX session store this invocation reads and writes. * @@ -122,7 +122,7 @@ export function createPlanHarness(options: { refuseProgress?: (chunk: string, index: number) => Operation; }): PlanHarness { const fake = createFakeAcp(); - const catalogCalls: string[][] = []; + const symbolCalls: string[][] = []; const reviews: ElicitationRequest[] = []; const answers: ScriptedReview[] = []; const progress: string[] = []; @@ -130,7 +130,7 @@ export function createPlanHarness(options: { const harness: PlanHarness = { fake, - catalogCalls, + symbolCalls, reviews, progress, script(review) { @@ -158,9 +158,9 @@ export function createPlanHarness(options: { sessionStore: options.store ?? makeStore(), agentRegistry: makeRegistry({ [AGENT]: `${AGENT}-cmd` }), }, - *catalog(includes) { - catalogCalls.push([...includes]); - return yield* (options.catalog ?? syntaxCatalog)(includes); + *symbols(includes) { + symbolCalls.push([...includes]); + return yield* (options.symbols ?? syntaxSymbols)(includes); }, authorshipRoot: options.authorshipRoot, *installElicitation() { @@ -292,15 +292,16 @@ export interface PlanDeclarationHarness { /** The declaration to attach to an execution. */ declaration: DeclaredMarkdownComponent; /** - * The catalog this case's execution describes. + * The symbols this case's execution describes. * * Attached to the execution rather than to the declaration, because that is - * where the profile a document observes is now settled: `` is public, - * canonical core owns it, and what it answers with is the execution's own. + * where the profile a document is shown is now settled: `` is + * public, canonical core owns it, and what it answers with is the execution's + * own. */ - catalog: CatalogContribution; - /** How many times that contribution was asked. */ - catalogCalls: number; + symbols: SyntaxSymbolsProvider; + /** How many times that provider was asked. */ + symbolCalls: number; /** Review answers, taken in order. Running out is a test defect, not a case. */ script(review: ScriptedReview): void; } @@ -319,13 +320,13 @@ export function* planDeclarationHarness(options: { authorshipRoot: string; includes?: readonly string[]; /** - * The catalog this case's execution describes, in place of the default below. + * The symbols this case's execution describes, in place of the default below. * - * A case that needs to know *when* the catalog was observed supplies this, - * which is the only way to tell an authored phase that precedes the - * observation from one that follows it. + * A case that needs to know *when* they were read supplies this, which is the + * only way to tell an authored phase that precedes the read from one that + * follows it. */ - catalog?: () => Operation; + symbols?: () => Operation; /** * How this case answers the one structural question the Component asks. * @@ -409,11 +410,11 @@ export function* planDeclarationHarness(options: { checked, reviews, declaration, - catalogCalls: 0, - *catalog(): Operation { - harness.catalogCalls += 1; - if (options.catalog !== undefined) { - return yield* options.catalog(); + symbolCalls: 0, + *symbols(): Operation { + harness.symbolCalls += 1; + if (options.symbols !== undefined) { + return yield* options.symbols(); } return CASE_CATALOG; }, @@ -430,7 +431,7 @@ export function* planDeclarationHarness(options: { * One entry, so the rendered catalog carries a marker a prompt assertion can * look for without depending on the whole run profile being assembled. */ -export const CASE_CATALOG: SyntaxCatalog = { +export const CASE_CATALOG: SyntaxSymbols = { version: 2, categories: [ { kind: "structural", entries: [] }, diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 9de97c1b..68addfa5 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -21,8 +21,8 @@ import { platform, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { API } from "@executablemd/runtime"; import { CORE_COMPONENT_NAMES } from "@executablemd/core"; -import type { PropsSchema, SyntaxCatalog } from "@executablemd/core"; -import { renderSyntaxJson, renderSyntaxMarkdown, syntaxCatalog } from "../src/syntax.ts"; +import type { PropsSchema, SyntaxSymbols } from "@executablemd/core"; +import { renderSyntaxJson, renderSyntaxMarkdown, syntaxSymbols } from "../src/syntax.ts"; function* useWorkspace( files: Record, @@ -59,7 +59,7 @@ const WORKSPACE: Record = { "second/Only.md": "only in the second include\n", }; -function parseCatalog(text: string): SyntaxCatalog { +function parseCatalog(text: string): SyntaxSymbols { const parsed: unknown = JSON.parse(text); if (typeof parsed !== "object" || parsed === null) { throw new Error("the catalog is not an object"); @@ -72,7 +72,7 @@ function parseCatalog(text: string): SyntaxCatalog { return { version, categories: readCategories(categories) }; } -function readCategories(categories: unknown[]): SyntaxCatalog["categories"] { +function readCategories(categories: unknown[]): SyntaxSymbols["categories"] { const [structural, builtIn, userProvided] = categories.map(readCategory); if ( structural?.kind !== "structural" || @@ -105,7 +105,7 @@ function names(entries: readonly { name: string }[]): string[] { } /** One built-in entry carrying `props`, for a renderer row that supplies its own. */ -function catalogWith(props: PropsSchema): SyntaxCatalog { +function catalogWith(props: PropsSchema): SyntaxSymbols { return { version: 2, categories: [ @@ -151,7 +151,7 @@ const COMPOSITION_NAMES = [ describe("Tier SX — the run profile the command describes", () => { it("SX1: names core, Agent, testing and web defaults, and ", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const builtIn = names(catalog.categories[1].entries); for (const name of CORE_COMPONENT_NAMES) { @@ -176,7 +176,7 @@ describe("Tier SX — the run profile the command describes", () => { }); it("SX1b: describes once, as the component canonical core owns", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const everywhere = catalog.categories.flatMap((category) => category.entries.filter((entry) => entry.name === "Syntax"), ); @@ -198,14 +198,14 @@ describe("Tier SX — the run profile the command describes", () => { expect(entry.captures).toEqual([]); expect(entry.returnMode).toBe("text"); expect(entry.description).toBe( - "Inspect components and control-flow constructs. `` renders the current " + - 'catalog; `` renders selected documentation.', + "Inspect available components and control-flow constructs. `` lists the " + + 'symbols available here; `` renders selected documentation.', ); expect(entry.as).toBe("Optional. Captures the rendered text instead of emitting it."); }); it("ORC1: names all thirteen repository-composition components, with contracts", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const entries = catalog.categories[1].entries; const builtIn = names(entries); @@ -248,7 +248,7 @@ describe("Tier SX — the run profile the command describes", () => { ].join("\n"), }, function* (dir) { - const catalog = yield* syntaxCatalog([dir]); + const catalog = yield* syntaxSymbols([dir]); const provided = catalog.categories[2].entries.find((entry) => entry.name === "Worktree"); expect(provided).toBeDefined(); expect(names(catalog.categories[1].entries)).not.toContain("Worktree"); @@ -257,7 +257,7 @@ describe("Tier SX — the run profile the command describes", () => { }); it("SX2: documents every complete built-in in the profile", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const undocumented = catalog.categories[1].entries.filter( (entry) => entry.description === undefined || entry.description.trim().length === 0, ); @@ -266,7 +266,7 @@ describe("Tier SX — the run profile the command describes", () => { }); it("SX2b: reports the testing contracts as they actually are", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const entries = catalog.categories[1].entries; const throws = entries.find((entry) => entry.name === "AssertThrows"); @@ -296,7 +296,7 @@ describe("Tier SX — the run profile the command describes", () => { }); it("SX3: describes without minting an execution claimant", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const session = catalog.categories[1].entries.find((entry) => entry.name === "Session"); // A registered default, described from the declaration a host makes: had @@ -313,7 +313,7 @@ describe("Tier SX — the run profile the command describes", () => { describe("Tier SX — the renderers take a value", () => { it("SX4: renders both formats without reaching the filesystem", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const rendered = yield* scoped(function* () { yield* API.Fs.around({ @@ -369,14 +369,14 @@ describe("Tier SX — the renderers take a value", () => { }); it("SX5: renders the same bytes twice from the same catalog", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); expect(renderSyntaxMarkdown(catalog)).toBe(renderSyntaxMarkdown(catalog)); expect(renderSyntaxJson(catalog)).toBe(renderSyntaxJson(catalog)); }); it("SX6: renders the fixed category headings in order", function* () { - const markdown = renderSyntaxMarkdown(yield* syntaxCatalog([])); + const markdown = renderSyntaxMarkdown(yield* syntaxSymbols([])); const headings = [ "## Built-in structural syntax", "## Built-in components", diff --git a/packages/cli/tests/verbose-component.test.ts b/packages/cli/tests/verbose-component.test.ts index 7e7e8ecc..69ac5ac7 100644 --- a/packages/cli/tests/verbose-component.test.ts +++ b/packages/cli/tests/verbose-component.test.ts @@ -1,12 +1,12 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { syntaxCatalog } from "../src/syntax.ts"; +import { syntaxSymbols } from "../src/syntax.ts"; describe("Tier VB — ", () => { // Inspection reads the declaration's metadata. It invokes nothing, so no // verbosity — the command line's or a component's — takes part in it. it("VB5: the run syntax catalog describes it", function* () { - const catalog = yield* syntaxCatalog([]); + const catalog = yield* syntaxSymbols([]); const verbose = catalog.categories[1].entries.find((entry) => entry.name === "Verbose"); expect(verbose?.origin).toEqual({ diff --git a/packages/core/host.ts b/packages/core/host.ts index 15c40ec1..43bd909f 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -69,10 +69,11 @@ export { executeInstalled } from "./src/execute.ts"; export type { ExecutionInstallation, JournalAdmission } from "./src/execute.ts"; /** - * The catalog a host's profile describes, when it is not the one the execution - * would derive from its own captured inputs — see `src/syntax-observation.ts`. + * The symbols a host's profile describes, when they are not the ones the + * execution would derive from its own captured inputs — see + * `src/syntax-reference.ts`. */ -export type { CatalogContribution } from "./src/syntax-observation.ts"; +export type { SyntaxSymbolsProvider } from "./src/syntax-reference.ts"; export type { DurablePreparation } from "./src/document-request.ts"; /** diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 7f60ebfb..3567808e 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -210,7 +210,7 @@ export type { InspectSyntaxOptions, OriginOnlyComponentSyntaxEntry, StructuralSyntaxEntry, - SyntaxCatalog, + SyntaxSymbols, } from "./src/inspect.ts"; export { ComponentIncludeError } from "./src/components/candidates.ts"; /** @@ -231,8 +231,26 @@ export { documentationIndexFor, packageDocumentation, } from "./src/component-documentation.ts"; -export type { DocumentationContribution } from "./src/component-documentation.ts"; -export { select as selectDocumented } from "./src/syntax-observation.ts"; +export type { + DocumentationContribution, + DocumentationReader, +} from "./src/component-documentation.ts"; +/** + * How a package's bootstrap contributes the documentation for what it registers. + * + * Exported because the contract is additive and contextual: a package installs + * its registrations and its documentation in one call, and canonical execution + * collects whatever the host bootstrapped. A host-maintained list of every + * package's documentation would be a second list beside the registrations, and + * two lists drift. + */ +export { + capturedDocumentation, + contributeDocumentation, + Documentation, +} from "./src/documentation-api.ts"; +export type { DocumentationApi } from "./src/documentation-api.ts"; +export { select as selectDocumented } from "./src/syntax-reference.ts"; export { NO_DOCUMENTATION, UnknownComponentError } from "./src/documentation-index.ts"; export type { DocumentationIndex } from "./src/documentation-index.ts"; export { PROTECTED_COMPONENT_NAMES, ProtectedComponentError } from "./src/components/protected.ts"; @@ -298,6 +316,7 @@ export { AGENT_REGISTRATIONS, agentIdentityComponents, installAgentComponents, + useAgentComponents, } from "./src/agent/components.ts"; export type { AgentComponentsOptions } from "./src/agent/components.ts"; export { Agent } from "./src/agent/agent-api.ts"; diff --git a/packages/core/src/agent/components.ts b/packages/core/src/agent/components.ts index 1ab783df..d4ba0763 100644 --- a/packages/core/src/agent/components.ts +++ b/packages/core/src/agent/components.ts @@ -25,6 +25,8 @@ import { Err, scoped, spawn, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import { Execution } from "../execute.ts"; import { registerComponents } from "../components/registration.ts"; +import { contributeDocumentation } from "../documentation-api.ts"; +import { agentDocumentation } from "../component-documentation.ts"; import { CORE_ORIGIN } from "../components/registry.ts"; import { createReplayStream } from "../replay-stream.ts"; import { documented } from "../components/documentation.ts"; @@ -200,6 +202,19 @@ export const AGENT_REGISTRATIONS: readonly ComponentRegistration[] = [ }, ]; +/** + * The agent vocabulary, as declarations and nothing else. + * + * Registrations and the documentation that describes them, installed together + * so a scope that has one has the other. `xmd syntax` enters exactly this and + * stops: describing an environment installs no provider, no launcher and no + * completion policy. + */ +export function* useAgentComponents(): Operation { + yield* registerComponents(AGENT_REGISTRATIONS); + yield* contributeDocumentation(agentDocumentation); +} + export function* installAgentComponents(options?: AgentComponentsOptions): Operation { if (options?.defaultAgent !== undefined) { const defaultAgent = options.defaultAgent; @@ -210,7 +225,7 @@ export function* installAgentComponents(options?: AgentComponentsOptions): Opera yield* AgentInternal.around({ permissionMode: () => permissionMode }, { at: "min" }); } - yield* registerComponents(AGENT_REGISTRATIONS); + yield* useAgentComponents(); const rootProvider = options?.rootProvider; diff --git a/packages/core/src/component-documentation.ts b/packages/core/src/component-documentation.ts index 89ec3e97..f8d564cf 100644 --- a/packages/core/src/component-documentation.ts +++ b/packages/core/src/component-documentation.ts @@ -56,15 +56,24 @@ export function* agentDocumentation( { owner: CORE_ORIGIN, asset: "packages/core/src/agent/components.md" }, read, ), - supplies: AGENT_COMPONENT_NAMES, + supplies: agentComponentNames(), }; } -/** Every component the Agent registration boundary supplies, by name. */ -const AGENT_COMPONENT_NAMES: ReadonlySet = new Set([ - ...AGENT_REGISTRATIONS.map((registration) => registration.name), - ...agentIdentityComponents().map((component) => component.name), -]); +/** + * Every component the Agent registration boundary supplies, by name. + * + * Built when asked rather than at module scope. The Agent bootstrap imports this + * module for its own contribution, so a module-scoped set built from + * `AGENT_REGISTRATIONS` would read that array before its own module finished + * evaluating, depending on which of the two was loaded first. + */ +function agentComponentNames(): ReadonlySet { + return new Set([ + ...AGENT_REGISTRATIONS.map((registration) => registration.name), + ...agentIdentityComponents().map((component) => component.name), + ]); +} /** Core's documentation source, read from the package rather than the caller. */ export function* readCoreDocumentation( @@ -157,32 +166,20 @@ export function* readPackagedDocumentation( * component this build actually ships; which of them a given site can select is * a separate question the selection answers. */ -export function* documentationIndexFor( +export function documentationIndexFor( /** - * What the packages installed in this execution supply, beside core's own. + * What the packages bootstrapped in this execution supply, core's own + * included. * - * Assembled by the trusted host, with the rest of the installation, before any - * document code exists — the Agent, CLI, testing, web and workflow bundles - * each contribute their own file and the set of components it must cover. Not - * a setter and not a context: a document that could add a source could - * describe components it does not have, and one that could remove a source - * could hide the documentation of a component it does. + * Collected through the `Documentation` Api at the trusted boundary, before + * any document code exists — core is the terminal, and the Agent, CLI, + * testing, web and workflow bootstraps each append their own file and the set + * of components it must cover. Nothing is added here: a second core entry + * appended by this function would be a source no bootstrap accounted for, and + * the collector's duplicate refusal would never see it. */ - contributed: readonly DocumentationContribution[] = [], - /** - * How this execution reads core's own asset. - * - * By value, from whoever built the observation. Nothing module-scoped, so two - * executions in one process each read through their own and neither can - * change what the other is told. - */ - read: DocumentationReader = packagedAssetReader, -): Operation { - const core: DocumentationContribution = { - source: yield* readCoreDocumentation(read), - supplies: CORE_COMPONENT_NAMES, - }; - const all = [core, ...contributed]; + all: readonly DocumentationContribution[], +): DocumentationIndex { // Merged per owner, not replaced. One package can have several registration // boundaries — core registers its own components and its Agent components // from two files — and keying by owner alone would let the second boundary's @@ -221,8 +218,12 @@ export interface DocumentationContribution { * Its registrations and the protected tier together — the two ways core puts a * component into an execution — read from the same declarations execution reads, * so this cannot drift from what the package actually ships. + * + * Deliberately not `CORE_COMPONENT_NAMES`, which is the registrations alone. + * Documentation has to account for the protected tier as well, and two sets + * under one name would eventually be used for each other's question. */ -const CORE_COMPONENT_NAMES: ReadonlySet = new Set([ +export const CORE_DOCUMENTED_NAMES: ReadonlySet = new Set([ ...CORE_REGISTRY.keys(), ...PROTECTED_COMPONENT_NAMES, ]); diff --git a/packages/core/src/components/Syntax.ts b/packages/core/src/components/Syntax.ts index a8875704..b8350bcc 100644 --- a/packages/core/src/components/Syntax.ts +++ b/packages/core/src/components/Syntax.ts @@ -3,12 +3,12 @@ * * An author asking "which components do I have here?" and an agent being told * what to write are the same question, and `xmd syntax` already answers it from - * outside. This is the same answer from inside: the catalog for the site the + * outside. This is the same answer from inside: the symbols for the site the * element was written at, as the Markdown that command prints. * * ## Why canonical core owns it * - * The catalog describes the vocabulary an execution actually has. A repository + * The symbols describe the vocabulary an execution actually has. A repository * `Syntax.md`, a bundled `Syntax`, a registration, an import handler or a second * loaded copy answering for the name would each describe a vocabulary the run * does not have — to whoever is reading, and to whichever agent is being told @@ -17,16 +17,17 @@ * selected is what runs. * * Protection is about the *answer*, not about power. The component receives one - * operation that observes catalog text and nothing else: no definitions, no + * reference that renders symbol text and nothing else: no definitions, no * import witness, no invocation capability, no policy table, no provider and no - * registration handle. A catalog naming a component is not permission to run it. + * registration handle. Naming a component in the symbols is not permission to + * run it. * * ## What one occurrence does * - * It claims the occurrence identity this execution minted, observes once, and - * retains exactly what it observed. A continuation reads that record and hands - * the same catalog back without consulting the filesystem, the registry, the - * bundle, the host or the lexical observation again — so an agent resuming + * It claims the occurrence identity this execution minted, renders once, and + * retains exactly what it rendered. A continuation reads that record and hands + * the same text back without consulting the filesystem, the registry, the + * bundle, the host or the lexical reference again — so an agent resuming * authorship is shown the vocabulary the run actually showed it, not one * rebuilt from a tree that has moved since. */ @@ -43,25 +44,25 @@ import type { ProtectedBody, } from "../invocation-identity.ts"; import { sourceDescription } from "../source-position.ts"; -import type { CatalogObservation } from "../syntax-observation.ts"; +import type { SyntaxReference } from "../syntax-reference.ts"; import type { ProtectedComponent } from "./protected.ts"; import { CORE_ORIGIN } from "./registry.ts"; import { documented } from "./documentation.ts"; import type { Json, PropsSchema, SourcePosition } from "../types.ts"; -/** The public name canonical core claims for the catalog component. */ +/** The public name canonical core claims for the syntax component. */ export const SYNTAX_COMPONENT = "Syntax"; /** The durable effect one occurrence records. */ -const SYNTAX_CATALOG = "syntax_catalog"; +const SYNTAX_SYMBOLS = "syntax_symbols"; /** - * No props at all, closed. + * One optional prop, closed. * - * The site decides what the catalog says; there is nothing for an author to - * select. A prop written here is refused before the body runs, which is what - * keeps a spelling nobody supports from quietly rendering the whole catalog - * anyway. + * The site decides what the symbols say; `names` decides only whether the + * occurrence renders the list or the selected documentation. Any other prop is + * refused before the body runs, which is what keeps a spelling nobody supports + * from quietly rendering every symbol anyway. */ export const props: PropsSchema = { type: "object", @@ -72,15 +73,15 @@ export const props: PropsSchema = { minItems: 1, uniqueItems: true, description: - "Optional. Render these components' catalog metadata and long-form documentation " + - "instead of the compact catalog. Entries render once each, in catalog order.", + "Optional. Render these components' metadata and long-form documentation " + + "instead of the list of available symbols. Entries render once each, in symbol order.", }, }, additionalProperties: false, }; const PAIRED_REFUSAL = - " renders the current catalog and reads no content, so it is written self-closing."; + " renders the available symbols and reads no content, so it is written self-closing."; const NAMES_REFUSAL = " takes a non-empty list of component names, each a string."; @@ -92,21 +93,21 @@ const DUPLICATE_REFUSAL = const UNISSUED_REFUSAL = " is invoked by canonical core; this is not an invocation the engine issued."; -const NO_OBSERVATION_REFUSAL = - " has no catalog to observe here: this expansion carries none, so nothing " + - "established what a document may write at this site."; +const NO_REFERENCE_REFUSAL = + " has no symbols to read here: this expansion carries no syntax reference, so " + + "nothing established what a document may write at this site."; const UNREADABLE_RECORD = - "the retained catalog is not a catalog this version can read, so no catalog was " + + "the retained text is not a record this version can read, so no symbols were " + "produced."; /** * The declaration canonical core selects for ``. * - * Self-closing only, no props, and no `returns` — which is what makes it a text - * component: the bare form emits the catalog through the current presentation - * middleware, and `as` captures the same text through the engine's ordinary - * capture and emits nothing. + * Self-closing only, one optional prop, and no `returns` — which is what makes + * it a text component: it emits through the current presentation middleware, + * and `as` captures the same text through the engine's ordinary capture and + * emits nothing. */ export const SYNTAX_PROTECTED: ProtectedComponent = { name: SYNTAX_COMPONENT, @@ -115,8 +116,8 @@ export const SYNTAX_PROTECTED: ProtectedComponent = { forms: ["self-closing"], ...documented({ description: - "Inspect components and control-flow constructs. `` renders the current " + - 'catalog; `` renders selected documentation.', + "Inspect available components and control-flow constructs. `` lists the " + + 'symbols available here; `` renders selected documentation.', as: "Optional. Captures the rendered text instead of emitting it.", context: null, }), @@ -124,15 +125,15 @@ export const SYNTAX_PROTECTED: ProtectedComponent = { }; function syntax(claim: IdentityClaimant): ProtectedBody { - return function* observeCatalog( + return function* renderSyntax( props: Record, invocation: ComponentInvocation, - observation: CatalogObservation | undefined, + reference: SyntaxReference | undefined, ): Operation { // Read off the issuance the engine holds rather than off a method the // caller could have written, and answered before anything is claimed or - // observed: a paired spelling is a document asking for something this - // component does not have, not a catalog to go and build. + // rendered: a paired spelling is a document asking for something this + // component does not have, not symbols to go and build. const form = invocationForm(invocation); if (form === undefined) { throw new ComponentInvocationError(UNISSUED_REFUSAL); @@ -140,7 +141,7 @@ function syntax(claim: IdentityClaimant): ProtectedBody { if (form === "paired") { throw new ComponentInvocationError(PAIRED_REFUSAL); } - // Read before anything is claimed or observed, so a list this component + // Read before anything is claimed or rendered, so a list this component // cannot answer for refuses with no durable record and no partial text. // The schema has already rejected an empty list, a duplicate and a // non-string member; what is left is whether the value is the array shape @@ -148,12 +149,12 @@ function syntax(claim: IdentityClaimant): ProtectedBody { // that somebody validated them. const names = requestedNames(props.names); const id = yield* claim(invocation); - if (observation === undefined) { - throw new Error(NO_OBSERVATION_REFUSAL); + if (reference === undefined) { + throw new Error(NO_REFERENCE_REFUSAL); } const expansion = yield* getExpansion(); - return yield* persistCatalog(id, expansion.position, () => - names === undefined ? observation.observe() : observation.document(names), + return yield* persistSymbols(id, expansion.position, () => + names === undefined ? reference.symbols() : reference.documentation(names), ); }; } @@ -165,7 +166,7 @@ function syntax(claim: IdentityClaimant): ProtectedBody { * and a non-string member before the body is entered. This is the second, and it * exists because a body is handed a props object rather than a promise that one * was checked: a value that is not the shape this reads is refused here rather - * than becoming an empty selection that renders the whole catalog. + * than becoming an empty selection that renders every symbol. */ function requestedNames(value: Json | undefined): readonly string[] | undefined { if (value === undefined) { @@ -187,47 +188,47 @@ function requestedNames(value: Json | undefined): readonly string[] | undefined return names; } -function* persistCatalog( +function* persistSymbols( id: string, position: Readonly | undefined, live: () => Operation, ): Workflow { const stored = yield createDurableOperation( { - type: SYNTAX_CATALOG, - name: `${SYNTAX_CATALOG}:${id}`, + type: SYNTAX_SYMBOLS, + name: `${SYNTAX_SYMBOLS}:${id}`, ...sourceDescription(position), }, function* (): Operation { - return { catalog: yield* live() }; + return { symbols: yield* live() }; }, ); - const catalog = readCatalog(stored); - if (catalog === undefined) { + const symbols = readSymbols(stored); + if (symbols === undefined) { // A record this version cannot read is the journal no longer describing // this run, not a component that failed: it travels as the stale input it // is, rather than becoming an error segment a printing boundary could turn // into text and carry on past. throw new StaleInputError(UNREADABLE_RECORD); } - return catalog; + return symbols; } /** - * The catalog a record holds, read as a closed protocol. + * The text a record holds, read as a closed protocol. * * Exactly one member, a string. A record missing it, carrying a member this * version does not know, or holding one of the wrong type is a record this * version cannot read — not one to fill a default in for, because every default * here is a guess about what an earlier run actually showed somebody. */ -function readCatalog(value: unknown): string | undefined { +function readSymbols(value: unknown): string | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; } - const catalog = Reflect.get(value, "catalog"); - if (Object.keys(value).length !== 1 || typeof catalog !== "string") { + const symbols = Reflect.get(value, "symbols"); + if (Object.keys(value).length !== 1 || typeof symbols !== "string") { return undefined; } - return catalog; + return symbols; } diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index b1766ff1..8ec5de6d 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -1,10 +1,10 @@ Long-form documentation for the components canonical core owns. Each level-two heading below is the exact name of one component. The compact -catalog — `xmd syntax`, or a bare `` — lists every component with its -forms, props and one-line description. This file holds the part that does not -belong in a list: when to reach for a component, what it does at run time, and -what it will refuse. +list of symbols — `xmd syntax`, or a bare `` — names every component +with its forms, props and one-line description. This file holds the part that +does not belong in a list: when to reach for a component, what it does at run +time, and what it will refuse. Every component this package supplies has a section here. A build in which one does not refuses rather than serving a reference with a silent hole in it: a @@ -14,15 +14,15 @@ no package governs. ## Syntax -Renders the catalog of components and control-flow constructs available where -the element is written. +Inspects the components and control-flow constructs available where the element +is written. ```mdx ``` -The bare form renders the compact catalog: every name a document may write at -that site, with its forms, props and description. It is the same text +The bare form lists the symbols available here: every name a document may write +at that site, with its forms, props and description. It is the same text `xmd syntax` prints, built by the same code, so an operator reading a terminal and an agent reading a document are never told different things about one profile. @@ -31,11 +31,11 @@ profile. ``` -The named form renders the selected components' catalog metadata followed by the +The named form renders the selected components' metadata followed by the long-form documentation on this page. Use it when something needs to know how to use a few specific components rather than what exists — a prompt that has to explain `` does not need the other seventy entries. Entries render once -each, in catalog order, whatever order they were asked for in. +each, in symbol order, whatever order they were asked for in. `as` captures the rendered text instead of emitting it, in either form: @@ -43,9 +43,9 @@ each, in catalog order, whatever order they were asked for in. ``` -### What the catalog describes +### What the symbols describe -The site, not the product. It reflects the host profile the execution is running +The site, not the product. They reflect the host profile the execution is running under, its working directory and includes, the workflow bundle or declared components it is closed over, and any narrowing a trusted evaluation boundary applied. Two sites in one document can therefore answer differently, and that is @@ -53,7 +53,7 @@ the point: the answer is what *this* element may write. Inside an evaluation that narrows what may execute, the bare form reports the narrowed vocabulary, while the named form still explains components from the -enclosing authoring catalog and states for each whether it is available in the +enclosing authoring symbols and states for each whether it is available in the current evaluation. Reference material and execution authority are different questions, and conflating them would either hide documentation an author needs or imply an authority they do not have. @@ -61,13 +61,13 @@ or imply an authority they do not have. ### What it refuses An empty `names` list, a duplicate name, a member that is not a string, and a -name no catalog entry matches are each refused before anything is observed, so a -refusal produces no partial catalog and no retained result. A paired spelling +name no symbol entry matches are each refused before anything is read, so a +refusal produces no partial text and no retained result. A paired spelling and any prop other than `names` and `as` are refused the same way. ### What it does not do -Seeing a component in a catalog is not permission to run it. The catalog and +Seeing a component named here is not permission to run it. The symbols and this documentation are text; what a name means is still resolution's decision, and what may run is still the execution's. diff --git a/packages/core/src/components/import-authority.ts b/packages/core/src/components/import-authority.ts index 144e1bf2..9c36d912 100644 --- a/packages/core/src/components/import-authority.ts +++ b/packages/core/src/components/import-authority.ts @@ -26,7 +26,7 @@ import type { } from "../invocation-identity.ts"; import type { DeclaredImports, PrivateClosure } from "./declared-markdown.ts"; import type { ExactSource } from "../output/exact-source.ts"; -import type { CatalogObservation } from "../syntax-observation.ts"; +import type { SyntaxReference } from "../syntax-reference.ts"; /** A definition an import may answer with. */ export type ImportedDefinition = ComponentDefinition | FunctionComponentDefinition; @@ -120,7 +120,7 @@ export interface ExpansionAuthority { * else changes it: an ordinary component's body, the content a caller * projected and an imported definition each carry what the site carried. */ - readonly catalog?: CatalogObservation; + readonly syntax?: SyntaxReference; /** * The bodies this execution will enter for the components canonical core * protects. diff --git a/packages/core/src/documentation-api.ts b/packages/core/src/documentation-api.ts new file mode 100644 index 00000000..8be9cbe8 --- /dev/null +++ b/packages/core/src/documentation-api.ts @@ -0,0 +1,148 @@ +/** + * How a package contributes the documentation for the components it registers. + * + * Documentation composes with the components it describes. A package's + * bootstrap installs its registrations and its documentation together, through + * this one namespaced Api, so a host that bootstraps a package gets both by + * invoking one thing. The alternative — a host-maintained list of every + * package's documentation, kept beside a host-maintained list of every + * package's registrations — is two lists that drift, and they did: a nested run + * registered `` and then reported it undocumented, because one list + * had been updated and the other had not. + * + * ## The terminal is core's own + * + * `contributions()` answers with core's own documentation and nothing else. A + * package wraps it, delegates, and appends its own: + * + * ```ts + * yield* Documentation.around({ + * *contributions([read], next) { + * return [...(yield* next(read)), yield* webDocumentation(read)]; + * }, + * }); + * ``` + * + * Composition is why order cannot choose a winner: every wrapper delegates, so + * every contribution reaches the collector, and two contributions for one + * component refuse there rather than the later one silently replacing the + * earlier. + * + * ## What an execution reads + * + * Canonical execution asks *once*, after trusted host bootstrap and before the + * root import or any document code, and snapshots the answer by value. So + * middleware a document or a component installs later composes into a chain + * nothing reads: what `` renders is what the host assembled, + * not what the document arranged afterwards. Two executions assembled in + * separate scopes see their own, because a scope is what an Api answer belongs + * to. + * + * The execution's own asset reader travels as the argument rather than being + * read from module scope, so every package's asset is read through the reader + * belonging to the execution that asked, and two executions in one process + * cannot read through each other's. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +import { + CORE_DOCUMENTED_NAMES, + packagedAssetReader, + readCoreDocumentation, +} from "./component-documentation.ts"; +import type { DocumentationContribution, DocumentationReader } from "./component-documentation.ts"; +import { DocumentationIndexError } from "./documentation-index.ts"; +import { snapshotContributions } from "./syntax-reference.ts"; + +/** What this Api answers. */ +export interface DocumentationApi { + /** + * Every bootstrapped package's documentation, in bootstrap order. + * + * Order is not authority: it decides how the list reads, and nothing else. + * Two contributions naming one component of one package refuse wherever they + * sit in it. + */ + contributions(read: DocumentationReader): Operation; +} + +/** + * The namespace a package's bootstrap reaches. + * + * Stable and namespaced so a separately loaded copy of a package composes here + * too: what makes two copies agree is the Api's name, not a shared module + * instance. It carries documentation and nothing else — no definitions, no + * import witnesses, no registration handle — so composing with it grants a + * package no authority it did not already have. + */ +export const Documentation: Api = createApi("Documentation", { + /** + * Canonical core's own documentation, as the terminal. + * + * Every chain ends here, so core's components are documented in an execution + * that bootstrapped no other package at all. + */ + *contributions(read: DocumentationReader): Operation { + return [{ source: yield* readCoreDocumentation(read), supplies: CORE_DOCUMENTED_NAMES }]; + }, +}); + +/** + * Add this package's documentation to whatever the enclosing scope contributes. + * + * The one call a package's bootstrap makes, beside registering its components. + * It delegates first and appends after, so nothing it composes over is lost and + * no bootstrap can answer for a package that is not its own. + * + * The contribution is read when the collector asks, not when this is called, + * and through the reader the collector supplies: a bootstrap installed in one + * execution's scope reads that execution's assets. + */ +export function* contributeDocumentation( + contribute: (read: DocumentationReader) => Operation, +): Operation { + yield* Documentation.around({ + *contributions([read], next): Operation { + return [...(yield* next(read)), yield* contribute(read)]; + }, + }); +} + +/** + * What this execution's packages contributed, captured by value. + * + * Asked once, where canonical execution is assembled: after the trusted host's + * bootstrap and before the root import, so what a document later installs + * composes into a chain nothing reads. Snapshotted field by field for the same + * reason the installation boundary snapshots anything — the objects belong to + * whoever built them, and their `Set`s and strings can move afterwards. + * + * Two contributions naming one component of one package refuse here, wherever + * they sat in the chain. A later one silently winning would make what a + * document is told about a component depend on the order its host happened to + * bootstrap packages in. + */ +export function* capturedDocumentation( + read: DocumentationReader = packagedAssetReader, +): Operation { + const contributed = yield* Documentation.operations.contributions(read); + const seen = new Map(); + for (const one of contributed) { + for (const name of one.supplies) { + const owner = one.source.owner; + const key = `${owner} ${name}`; + const first = seen.get(key); + if (first !== undefined && first !== one.source.asset) { + throw new DocumentationIndexError( + `${owner} contributes documentation for ${name} from both ${first} and ` + + `${one.source.asset}. One component of one package has one documentation source, ` + + "whichever order the packages bootstrapped in.", + ); + } + seen.set(key, one.source.asset); + } + } + return snapshotContributions(contributed); +} diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index c5f8d3b5..0ddda0f0 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -140,10 +140,11 @@ import type { IdentityComponent } from "./invocation-identity.ts"; import { ExecutionImports } from "./components/import-authority.ts"; import type { ExpansionAuthority, ImportTier } from "./components/import-authority.ts"; import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; -import { rootCatalogObservation, snapshotContributions } from "./syntax-observation.ts"; +import { rootSyntaxReference } from "./syntax-reference.ts"; +import { capturedDocumentation } from "./documentation-api.ts"; import { packagedAssetReader } from "./component-documentation.ts"; import type { DocumentationContribution, DocumentationReader } from "./component-documentation.ts"; -import type { CatalogContribution } from "./syntax-observation.ts"; +import type { SyntaxSymbolsProvider } from "./syntax-reference.ts"; import type { WorkflowComponentBundle, WorkflowImportAuthority } from "./components/bundle.ts"; import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; import { readRootSource, rootSourcePath } from "./root-source.ts"; @@ -878,7 +879,7 @@ function readRootSelection(value: unknown): RootImportRecord { } // Same standard for a failure: the recorded selector must fail against the // recorded content in exactly the way the record claims. That verifies the - // catalog and the matches too, which no amount of shape checking could. + // symbols and the matches too, which no amount of shape checking could. const rederived = findTarget(outline, failure.selector); if (rederived.ok) { return MALFORMED; @@ -2161,13 +2162,13 @@ function* executeDocument( bundles: readonly WorkflowComponentBundle[] = [], identityComponents: readonly IdentityComponent[] = [], declarations: readonly DeclaredMarkdownComponent[] = [], - catalogs: readonly CatalogContribution[] = [], + providers: readonly SyntaxSymbolsProvider[] = [], /** - * The documentation each installed package contributes. + * The documentation each bootstrapped package contributed. * - * Carried by value from the installation boundary, like the catalog beside - * it, so the index a document's own `` reads is the index - * the profile actually assembled. + * Carried by value from the collection boundary, like the symbols provider + * beside it, so the index a document's own `` reads is the + * index the profile actually assembled. */ documentation: readonly DocumentationContribution[] = [], /** This execution's packaged-asset reader, carried by value from the caller. */ @@ -2347,9 +2348,9 @@ function* executeDocument( exact: createExactSource(), // Built from what this execution captured before any installation, // middleware or document code ran, and asked only when an occurrence - // observes: a run whose document never writes `` enumerates + // renders: a run whose document never writes `` enumerates // nothing. - catalog: rootCatalogObservation( + syntax: rootSyntaxReference( { includes, registry: startingRegistry, @@ -2357,9 +2358,8 @@ function* executeDocument( declarations, ...(bundle === undefined ? {} : { workflow: bundle }), }, - catalogs[0], + providers[0], documentation, - readAsset, ), }; @@ -2636,33 +2636,25 @@ export interface ExecutionInstallation { */ readonly components?: readonly IdentityComponent[]; /** - * The catalog this host's profile describes, when its profile is not the one + * The symbols this host's profile describes, when its profile is not the one * the execution itself would derive. * * Captured by value alongside the admissions, before any installation runs, - * for the reason the rest are: what a document observes is settled before + * for the reason the rest are: what a document is shown is settled before * anything can observe or replace it. Omitted is the ordinary case — canonical - * core derives the catalog from the selection inputs this execution captured, - * which is what makes an ordinary run's observation the run's own. + * core derives the symbols from the selection inputs this execution captured, + * which is what makes an ordinary run's reference the run's own. * * `xmd plan` states one, because the Plan being written is a program a later * `xmd run` executes: the vocabulary the agent must be shown is that profile's * rather than the authorship execution's. One execution accepts one. - */ - readonly catalog?: CatalogContribution; - /** - * The long-form documentation this installation's packages ship. * - * One entry per registration boundary that documents its components, derived - * from the same declarations the installation registers. Captured by value - * before any document code runs: a document that could add a contribution - * could describe components it does not have, and one that could remove a - * contribution could hide the documentation of a component it does. - * - * Several are ordinary, unlike `catalog` — a profile installing four packages - * has four boundaries — so they are collected rather than refused. + * Documentation does not travel here. It is additive — a profile installing + * four packages has four boundaries — so each package's bootstrap contributes + * its own through the `Documentation` Api, and this execution collects them + * once at the same boundary. */ - readonly documentation?: readonly DocumentationContribution[]; + readonly symbols?: SyntaxSymbolsProvider; install?(): Operation; } @@ -2751,7 +2743,7 @@ function* runInvocation( * How this execution reads its packaged documentation assets. * * Defaulted to the real filesystem reader and carried by value from here into - * the observation, so it belongs to this execution alone. + * the syntax reference, so it belongs to this execution alone. */ readAsset: DocumentationReader = packagedAssetReader, observed?: () => void, @@ -3064,35 +3056,19 @@ function* invoke( ); // Read once and frozen with the rest, and before any installation runs: which - // profile a document observes is settled before anything can observe it. Two - // are refused rather than ordered — a catalog chosen by installation order + // profile a document is shown is settled before anything can replace it. Two + // are refused rather than ordered — symbols chosen by installation order // would make what an agent is told to write depend on assembly order. - const catalogs = Object.freeze( + const providers = Object.freeze( installations.flatMap((installation) => { - const catalog = installation.catalog; - return catalog === undefined ? [] : [catalog]; + const provider = installation.symbols; + return provider === undefined ? [] : [provider]; }), ); - // The documentation each installed package contributes, captured here with - // the rest of the installation and carried by value. Unlike the catalog - // above, several are ordinary: one registration boundary is one file, and a - // profile that installs four packages has four. Collecting them here is what - // makes `` and `xmd syntax NAME` read one index — the - // component reached a core-only index before this, so an Agent component had - // documentation on the command line and the fallback sentence in a document. - // - // Snapshotted here, field by field, and *before* any `install()` runs below. - // A shallow copy of the array would still hold the caller's source objects - // and name sets, so an installation could rewrite its own documentation from - // inside its `install()` — after the boundary that is supposed to have fixed - // it — and a document would be told whatever it changed them to. - const documentation = snapshotContributions( - installations.flatMap((installation) => [...(installation.documentation ?? [])]), - ); - if (catalogs.length > 1) { + if (providers.length > 1) { throw new Error( - "two installations stated the catalog this execution describes. One execution describes " + - "one vocabulary, so which profile a document observes is never a question of order.", + "two installations stated the symbols this execution describes. One execution describes " + + "one vocabulary, so which profile a document is shown is never a question of order.", ); } @@ -3102,6 +3078,16 @@ function* invoke( } } + // The documentation this execution's packages contributed, collected once + // here and snapshotted by value. + // + // Asked *after* the trusted host's bootstrap, because that is where a package + // installs its registrations and its documentation together, and *before* the + // root import and every element below it, because what a document is told + // about a component must not be something the document arranged. Middleware + // installed later composes into a chain nothing reads again. + const documentation = yield* capturedDocumentation(readAsset); + const issued = issueExecution(options); // The terminal for this invocation and no other. @@ -3134,7 +3120,7 @@ function* invoke( bundles, identityComponents, declarations, - catalogs, + providers, documentation, readAsset, ); @@ -3203,7 +3189,7 @@ export function executeInstalled( * executions in one process are unaffected by each other. * * It exists because cancelling *inside documentation-index construction* is a - * different claim from cancelling inside catalog discovery, and there is no + * different claim from cancelling inside symbol discovery, and there is no * other point in that operation a test can stand at. */ export function executeReadingAssetsWith( diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 5a4e2512..9a1439fd 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -3222,14 +3222,14 @@ function* expandFunctionComponent( return yield* definition.fn.invoke(validatedProps, binding); } // A component canonical core protects reads one lexical fact — the - // catalog for this site — and the fact changes as expansion descends, - // so it cannot be closed over when the implementation is built. It is - // delivered here instead, by the copy of core performing the - // expansion, from the authority it is already holding. + // syntax reference for this site — and the fact changes as expansion + // descends, so it cannot be closed over when the implementation is + // built. It is delivered here instead, by the copy of core performing + // the expansion, from the authority it is already holding. const guarded = authority?.protectedBodies?.body(definition.fn); if (guarded !== undefined) { try { - return yield* guarded(validatedProps, issued.invocation, authority?.catalog); + return yield* guarded(validatedProps, issued.invocation, authority?.syntax); } finally { issued.close(); } diff --git a/packages/core/src/inspect.ts b/packages/core/src/inspect.ts index 12b8e4c0..8757e3c7 100644 --- a/packages/core/src/inspect.ts +++ b/packages/core/src/inspect.ts @@ -183,7 +183,7 @@ export type ComponentInfo = /** * What a fully describable component reports beyond its schemas. * - * The same values the catalog carries, built by the same code, so describing + * The same values the symbols carry, built by the same code, so describing * one name and describing the whole environment cannot disagree. `returns` * above stays the *declared* schema — absent in text mode — while `returnMode` * is what tells the two apart. @@ -277,7 +277,7 @@ export function* inspectComponent(options: InspectComponentOptions): Operation { +export function* inspectSyntax(options: InspectSyntaxOptions): Operation { const includes = options.includes ?? DEFAULT_INCLUDES; const bundled = options.workflow; const declared = options.components ?? []; @@ -499,7 +499,7 @@ export function* inspectSyntax(options: InspectSyntaxOptions): Operation, invocation: ComponentInvocation, - observation: CatalogObservation | undefined, + observation: SyntaxReference | undefined, ) => Operation; /** @@ -479,7 +479,7 @@ export function isFormDispatcher(fn: unknown): boolean { * The only ways a forms declaration may be written. * * A closed list rather than a set membership test, because the *order* is part - * of the declaration: one canonical spelling per meaning means a catalog can be + * of the declaration: one canonical spelling per meaning means two entries can be * compared without normalizing, and a reader never has to wonder whether * `["paired", "self-closing"]` said something different. */ diff --git a/packages/core/src/syntax-markdown.ts b/packages/core/src/syntax-markdown.ts index 3e409e98..3d11437e 100644 --- a/packages/core/src/syntax-markdown.ts +++ b/packages/core/src/syntax-markdown.ts @@ -1,5 +1,5 @@ /** - * The catalog as Markdown a person reads. + * The symbols as Markdown a person reads. * * One renderer, in core, because two things print it: `xmd syntax`, which * describes an environment without running it, and canonical ``, which @@ -8,7 +8,7 @@ * owned could only be reached by the CLI, and a component in core would have * needed a second one. * - * It takes the catalog as a value. It discovers nothing, reads no filesystem, + * It takes the symbols as a value. It discovers nothing, reads no filesystem, * resolves no name and parses no other projection's output, so what it prints is * exactly what construction decided. * @@ -20,13 +20,13 @@ import type { CompleteComponentSyntaxEntry, OriginOnlyComponentSyntaxEntry, StructuralSyntaxEntry, - SyntaxCatalog, + SyntaxSymbols, } from "./inspect.ts"; import { NO_DOCUMENTATION } from "./documentation-index.ts"; import type { ComponentOrigin, Json, PropsSchema } from "./types.ts"; -/** The three category kinds, taken from the catalog rather than restated. */ -type CategoryKind = SyntaxCatalog["categories"][number]["kind"]; +/** The three category kinds, taken from the symbols rather than restated. */ +type CategoryKind = SyntaxSymbols["categories"][number]["kind"]; const HEADINGS: Record = { structural: "## Built-in structural syntax", @@ -40,8 +40,8 @@ const EMPTY: Record = { "user-provided": "No components were found in the configured includes.", }; -export function renderSyntaxMarkdown(catalog: SyntaxCatalog): string { - const sections = catalog.categories.map((category) => { +export function renderSyntaxMarkdown(symbols: SyntaxSymbols): string { + const sections = symbols.categories.map((category) => { const blocks: string[] = [HEADINGS[category.kind]]; if (category.entries.length === 0) { blocks.push(EMPTY[category.kind]); @@ -55,7 +55,7 @@ export function renderSyntaxMarkdown(catalog: SyntaxCatalog): string { return `${sections.join("\n\n")}\n`; } -/** One catalog entry, as the named form selects it. */ +/** One symbol entry, as the named form selects it. */ export interface SelectedEntry { readonly entry: | StructuralSyntaxEntry @@ -67,7 +67,7 @@ export interface SelectedEntry { * Whether the current evaluation can actually run this component. * * Stated rather than implied, because the named form reads from the enclosing - * authoring catalog: inside a narrowed evaluation it can explain a component + * authoring symbols: inside a narrowed evaluation it can explain a component * the evaluation may not execute, and a reader shown documentation with no * word about availability would reasonably assume they had both. */ diff --git a/packages/core/src/syntax-observation.ts b/packages/core/src/syntax-reference.ts similarity index 58% rename from packages/core/src/syntax-observation.ts rename to packages/core/src/syntax-reference.ts index 286112da..3729a421 100644 --- a/packages/core/src/syntax-observation.ts +++ b/packages/core/src/syntax-reference.ts @@ -3,39 +3,39 @@ * * `xmd syntax` answers that question for an environment nobody is running. * Canonical `` answers it for the site an element was actually written - * at, and the two have to be the same answer — a catalog an agent is shown and - * a catalog an operator prints describe one vocabulary or they describe none. + * at, and the two have to be the same answer — symbols an agent is shown and + * symbols an operator prints describe one vocabulary or they describe none. * * So there is one construction and one renderer, and this module is where an - * execution keeps its own use of them. The observation is built from the + * execution keeps its own use of them. The reference is built from the * selection inputs the execution captured before any installation, middleware or * document code ran: the includes it resolves against, the registry it started * with, the identity components and exact Markdown its host declared, and the * component bundle it is closed over when it has one. Nothing is read from a * context, a registry answer, or anything a document can reach. * - * A trusted host may state the catalog for its own profile instead. `xmd plan` + * A trusted host may state the symbols for its own profile instead. `xmd plan` * does: a Plan is written to be run by `xmd run`, so the vocabulary the agent * must be shown is the run profile's rather than the authorship execution's. - * That contribution is captured with the rest of the installation, before any + * That provider is captured with the rest of the installation, before any * installed code exists, and one execution accepts one — two are refused rather - * than ordered, because ordering them would make which profile a document - * observes depend on installation order. + * than ordered, because ordering them would make which profile a document is + * shown depend on installation order. * - * The observation carries no authority at all. It answers with text. Seeing a - * component named in a catalog neither registers it, resolves it, nor authorizes - * it: what a name means is still `selectComponent()`'s decision, and what may - * run is still the execution's. + * The reference carries no authority at all. It answers with text. Seeing a + * component named in the symbols neither registers it, resolves it, nor + * authorizes it: what a name means is still `selectComponent()`'s decision, and + * what may run is still the execution's. */ import type { Operation } from "effection"; import { inspectSyntax } from "./inspect.ts"; -import type { SyntaxCatalog } from "./inspect.ts"; +import type { SyntaxSymbols } from "./inspect.ts"; import { renderSelectedDocumentation, renderSyntaxMarkdown } from "./syntax-markdown.ts"; import type { SelectedEntry } from "./syntax-markdown.ts"; -import { documentationIndexFor, packagedAssetReader } from "./component-documentation.ts"; -import type { DocumentationContribution, DocumentationReader } from "./component-documentation.ts"; +import { documentationIndexFor } from "./component-documentation.ts"; +import type { DocumentationContribution } from "./component-documentation.ts"; import type { DocumentationIndex } from "./documentation-index.ts"; import { UnknownComponentError } from "./documentation-index.ts"; import type { WorkflowImportAuthority } from "./components/bundle.ts"; @@ -44,62 +44,62 @@ import type { IdentityComponent } from "./invocation-identity.ts"; import type { ComponentOrigin, ComponentRegistry } from "./types.ts"; /** - * The catalog in scope for the segments being expanded. + * The symbols in scope for the segments being expanded. * * Held by the execution and handed to core's own expansion by value, beside the * import authority and the identity domains. It is not a Context: a context * resolves by name, and a name is not a secret, so a document could build one * and answer for the vocabulary it is shown. */ -export interface CatalogObservation { - /** The catalog this site describes, rendered as Markdown. */ - observe(): Operation; +export interface SyntaxReference { + /** The symbols this site describes, rendered as Markdown. */ + symbols(): Operation; /** * The selected components' metadata and long-form documentation. * - * Two inputs, not one, and this is the reason the observation is an object + * Two inputs, not one, and this is the reason the reference is an object * rather than a string. *What may I write here* and *what may I read about* * are different questions, and a narrowing evaluation boundary answers them * differently on purpose: the vocabulary it admits is smaller than the * vocabulary an author is entitled to understand. * - * So selection reads the **enclosing authoring catalog**, which is why a + * So selection reads the **enclosing authoring symbols**, which is why a * nested Plan can be told how `` works even where it may not run one, * and each rendered entry states whether it is available in the current * evaluation. Collapsing the two would either hide reference material an * author needs or imply an authority they do not have. */ - document(names: readonly string[]): Operation; + documentation(names: readonly string[]): Operation; /** - * The observation for a subtree that may execute less than this site. + * The reference for a subtree that may execute less than this site. * * The narrowing seam, and it belongs here rather than in the evaluator * because everything it needs is already here. A canonical evaluation * boundary that has admitted a vocabulary hands it over; what comes back - * reports that vocabulary from `observe()` and keeps *this* observation's - * authoring catalog and documentation index for `document()`. + * reports that vocabulary from `symbols()` and keeps *this* reference's + * authoring symbols and documentation index for `documentation()`. * * Deriving it any other way would mean the evaluator recovering the raw * contributions and rebuilding an index — which is both a hole (that list is * execution-private for a reason) and a way for the two indexes to drift. * Narrowing what may run is not narrowing what may be read about, and the - * observation is the thing that already knows both. + * reference is the thing that already knows both. */ - narrow(executable: SyntaxCatalog): CatalogObservation; + available(symbols: SyntaxSymbols): SyntaxReference; } /** - * A trusted host's statement of the catalog its profile describes. + * A trusted host's statement of the symbols its profile describes. * * Captured by value with the rest of the installation, before any installed - * code, middleware or document code runs. It returns the catalog and core - * renders it, so a host cannot make its profile print differently from the way - * `xmd syntax` prints the same catalog. + * code, middleware or document code runs. It returns the symbols and core + * renders them, so a host cannot make its profile print differently from the + * way `xmd syntax` prints the same symbols. */ -export type CatalogContribution = () => Operation; +export type SyntaxSymbolsProvider = () => Operation; -/** The selection inputs an execution captured, as catalog construction reads them. */ -export interface CapturedCatalogInputs { +/** The selection inputs an execution captured, as symbol construction reads them. */ +export interface CapturedSymbolInputs { readonly includes: readonly string[]; /** The registrations this execution started with, captured before it ran. */ readonly registry: ComponentRegistry; @@ -110,20 +110,20 @@ export interface CapturedCatalogInputs { } /** - * The observation one execution's root carries. + * The reference one execution's root carries. * * Nothing is built until an occurrence asks. An execution whose document never * writes `` enumerates no includes, parses no component and reads no - * frontmatter, so carrying the observation costs a run that does not use it + * frontmatter, so carrying the reference costs a run that does not use it * nothing at all. * - * Each ask builds afresh. Two authored occurrences are two observations, which - * is what makes an occurrence's retained catalog its own rather than a copy of + * Each ask builds afresh. Two authored occurrences are two references, which + * is what makes an occurrence's retained symbols its own rather than a copy of * whichever one ran first. */ -export function rootCatalogObservation( - inputs: CapturedCatalogInputs, - contribution: CatalogContribution | undefined, +export function rootSyntaxReference( + inputs: CapturedSymbolInputs, + provider: SyntaxSymbolsProvider | undefined, /** * The documentation the installed packages contribute. * @@ -132,79 +132,68 @@ export function rootCatalogObservation( * component answer with documentation on the command line and with the * fallback sentence inside a document. */ - documentation: readonly DocumentationContribution[] = [], - /** - * How this execution reads its packaged assets. - * - * Held by the observation, so it belongs to this execution and no other. It - * reaches here from where the execution was built and from nowhere else — - * there is no setter, no context and no installation field that names it, so - * a document, a component or an installed package cannot substitute one, and - * a second execution in the same process is unaffected by this one's. - */ - read: DocumentationReader = packagedAssetReader, -): CatalogObservation { - function* current(): Operation { - return contribution === undefined ? yield* derived(inputs) : yield* contribution(); + contributions: readonly DocumentationContribution[] = [], +): SyntaxReference { + function* current(): Operation { + return provider === undefined ? yield* derived(inputs) : yield* provider(); } - // Snapshotted once, here, so the contributions an observation reads are the - // ones the installation boundary captured rather than whatever the caller's + // Snapshotted once, here, so the contributions a reference reads are the + // ones the collection boundary captured rather than whatever the caller's // objects hold by the time a document asks. - const captured = snapshotContributions(documentation); + const captured = snapshotContributions(contributions); // No admission at a root: nothing has narrowed what may execute, so the one - // catalog this resolves is both what a document may write and what it may - // read about. - return observing(current, undefined, captured, read); + // set of symbols this resolves is both what a document may write and what it + // may read about. + return referencing(current, undefined, captured); } /** - * One observation over an authoring catalog and an executable one. + * One reference over authoring symbols and executable ones. * - * `reference` is what named lookup reads and `executable` is what may run. At a - * root they are the same operation; a narrowed observation keeps the reference - * and replaces the executable, which is the whole of the seam. + * `authoring` is what named lookup reads and `admitted` is what may run. At a + * root they are the same operation; a narrowed reference keeps the authoring + * symbols and replaces the admitted ones, which is the whole of the seam. */ -function observing( - /** The authoring catalog: what may be read about here. */ - reference: () => Operation, +function referencing( + /** The authoring symbols: what may be read about here. */ + authoring: () => Operation, /** * What may *execute* here, when a boundary has narrowed it. * - * Absent at a root, where the two are the same catalog — and must be the same - * *value*. Resolving twice would call the trusted catalog contribution twice + * Absent at a root, where the two are the same symbols — and must be the same + * *value*. Resolving twice would call the trusted symbols provider twice * for one occurrence, and the environment could move between the two calls: - * an entry's metadata would then come from a different catalog than the + * an entry's metadata would then come from different symbols than the * availability reported beside it. */ - admitted: SyntaxCatalog | undefined, - documentation: readonly DocumentationContribution[], - read: DocumentationReader, -): CatalogObservation { + admitted: SyntaxSymbols | undefined, + contributions: readonly DocumentationContribution[], +): SyntaxReference { return { - *observe(): Operation { - // A narrowed observation reports its admission and asks the enclosing - // catalog for nothing — the bare form is about what runs. - return renderSyntaxMarkdown(admitted ?? (yield* reference())); + *symbols(): Operation { + // A narrowed reference reports its admission and asks the enclosing + // symbols for nothing — the bare form is about what runs. + return renderSyntaxMarkdown(admitted ?? (yield* authoring())); }, - *document(names: readonly string[]): Operation { + *documentation(names: readonly string[]): Operation { // One resolution, both decisions. - const authoring = yield* reference(); - const runnable = admitted ?? authoring; - const index = yield* documentationIndexFor(documentation, read); - return renderSelectedDocumentation(select(authoring, runnable, names, index)); + const readable = yield* authoring(); + const runnable = admitted ?? readable; + const index = documentationIndexFor(contributions); + return renderSelectedDocumentation(select(readable, runnable, names, index)); }, - narrow(next: SyntaxCatalog): CatalogObservation { - // The enclosing reference and the enclosing index, unchanged. Only what - // may execute is replaced, so a nested author keeps the documentation - // they had and every entry reports its availability against the - // admission. - return observing(reference, next, documentation, read); + available(next: SyntaxSymbols): SyntaxReference { + // The enclosing authoring symbols and the enclosing contributions, + // unchanged. Only what may execute is replaced, so a nested author keeps + // the documentation they had and every entry reports its availability + // against the admission. + return referencing(authoring, next, contributions); }, }; } /** - * One catalog entry's identity: its name and its complete origin. + * One symbol entry's identity: its name and its complete origin. * * Every member of the origin participates, not just its kind — a workflow blob * differs from another by `sourceHash`, a declared component by `digest`, two @@ -232,35 +221,35 @@ function identityOf(entry: { name: string; origin: ComponentOrigin }): string { } /** - * The selected entries, in catalog order, with their documentation and + * The selected entries, in symbol order, with their documentation and * availability. * - * `reference` is the catalog selection reads; `executable` is what the current + * `authoring` is what selection reads; `admitted` is what the current * evaluation may actually run. At a root they are the same object. Under a * narrowing boundary they are not, and the difference is what each entry's * availability reports. */ export function select( - reference: SyntaxCatalog, - executable: SyntaxCatalog, + authoring: SyntaxSymbols, + admitted: SyntaxSymbols, names: readonly string[], index: DocumentationIndex, ): SelectedEntry[] { const requested = new Set(names); // Keyed by identity, not by name. A name is a spelling, and the whole point of - // the two inputs is that the enclosing catalog may hold a *different* + // the two inputs is that the enclosing symbols may hold a *different* // component under the same one: an authoring entry for the built-in `Elicit` // beside an admitted repository `Elicit.md` is two components. Reporting the - // reference entry as available because something called `Elicit` can run + // authoring entry as available because something called `Elicit` can run // would tell an author they may execute the thing they were just shown. const runnable = new Set( - executable.categories.flatMap((category) => category.entries.map(identityOf)), + admitted.categories.flatMap((category) => category.entries.map(identityOf)), ); const selected: SelectedEntry[] = []; - // Walked in catalog order rather than request order, so two documents asking + // Walked in symbol order rather than request order, so two documents asking // for the same components in different orders render the same text — which is // what makes one occurrence's retained result comparable with another's. - for (const category of reference.categories) { + for (const category of authoring.categories) { for (const entry of category.entries) { // Components only. `names` is a component lookup under the current // contract, so a structural construct is not a thing this can select — @@ -289,7 +278,7 @@ export function select( return selected; } -function* derived(inputs: CapturedCatalogInputs): Operation { +function* derived(inputs: CapturedSymbolInputs): Operation { return yield* inspectSyntax({ includes: inputs.includes, registry: inputs.registry, @@ -299,15 +288,6 @@ function* derived(inputs: CapturedCatalogInputs): Operation { }); } -/** - * An observation over a catalog a trusted boundary already decided. - * - * The narrowing seam. A canonical evaluation boundary that has already admitted - * the exact vocabulary a subtree may write installs the corresponding catalog - * for that subtree, and the enclosing observation is restored on leaving it. It - * adds nothing: the catalog handed here is the admission's, so an entry that is - * not in the admission cannot be in the observation. - */ /** * A defensive copy of what a caller handed the installation boundary. * @@ -335,43 +315,49 @@ export function snapshotContributions( ); } -export function fixedCatalogObservation( - catalog: SyntaxCatalog, +/** + * A reference over symbols a trusted boundary already decided. + * + * The narrowing seam. A canonical evaluation boundary that has already admitted + * the exact vocabulary a subtree may write installs the corresponding symbols + * for that subtree, and the enclosing reference is restored on leaving it. It + * adds nothing: the symbols handed here are the admission's, so an entry that is + * not in the admission cannot be in the reference. + */ +export function syntaxReference( + admitted: SyntaxSymbols, /** - * The authoring catalog this boundary is nested in. + * The authoring symbols this boundary is nested in. * - * Where the two inputs come apart. `catalog` is what may *execute* here, and + * Where the two inputs come apart. `admitted` is what may *execute* here, and * this is what may be *read about* — the vocabulary of the site the evaluation * was written at. Omitted, the two are the same, which is the ordinary case * for a boundary that narrows nothing. * * A narrowing boundary passes both, and named selection then explains a * component this evaluation cannot run while saying so on the entry. Dropping - * the enclosing catalog instead would leave a nested author unable to look up + * the enclosing symbols instead would leave a nested author unable to look up * the very components they are being asked to write about. */ - reference: SyntaxCatalog = catalog, + authoring: SyntaxSymbols = admitted, /** * The enclosing execution's documentation contributions, carried across the * seam. * * Narrowing what may *execute* does not narrow what an author may read about: * the enclosing authoring documentation travels in with the enclosing - * catalog, so a nested author keeps the reference material they had. #713 - * installs the executable catalog; this is the index that goes with it. + * symbols, so a nested author keeps the reference material they had. #713 + * installs the executable symbols; this is the index that goes with it. */ - documentation: readonly DocumentationContribution[] = [], - /** How this observation reads packaged assets — this execution's, by value. */ - read: DocumentationReader = packagedAssetReader, -): CatalogObservation { - const captured = snapshotContributions(documentation); - return observing( + contributions: readonly DocumentationContribution[] = [], +): SyntaxReference { + const captured = snapshotContributions(contributions); + return referencing( // deno-lint-ignore require-yield function* () { - return reference; + return authoring; }, - catalog, + admitted, captured, - read, ); } diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 2d6d661b..8339704b 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -39,7 +39,7 @@ import type { CompleteComponentSyntaxEntry, OriginOnlyComponentSyntaxEntry, StructuralSyntaxEntry, - SyntaxCatalog, + SyntaxSymbols, } from "../mod.ts"; import type { IdentityComponent } from "../host.ts"; import type { InvocationForm } from "../mod.ts"; @@ -188,23 +188,23 @@ function catalogFor( tree: Tree, includes: readonly string[], enumeration: Enumeration = {}, -): Operation { +): Operation { return scoped(function* () { yield* useTree(tree, enumeration); return yield* inspectSyntax({ includes }); }); } -function structural(catalog: SyntaxCatalog): readonly StructuralSyntaxEntry[] { +function structural(catalog: SyntaxSymbols): readonly StructuralSyntaxEntry[] { return catalog.categories[0].entries; } -function builtIn(catalog: SyntaxCatalog): readonly CompleteComponentSyntaxEntry[] { +function builtIn(catalog: SyntaxSymbols): readonly CompleteComponentSyntaxEntry[] { return catalog.categories[1].entries; } function userProvided( - catalog: SyntaxCatalog, + catalog: SyntaxSymbols, ): readonly (CompleteComponentSyntaxEntry | OriginOnlyComponentSyntaxEntry)[] { return catalog.categories[2].entries; } @@ -1153,7 +1153,7 @@ describe("Tier SY: inspection is observation, never authority", () => { }, }); - let catalog: SyntaxCatalog | undefined; + let catalog: SyntaxSymbols | undefined; const failure = yield* raised( scoped(function* () { yield* useTree({}); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 762b9388..602481f2 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -2,27 +2,27 @@ * Tier SYN — ``, the component canonical core owns. * * What a document may write here is a public question, and this is the public - * answer: the catalog for the site the element was written at, in the words + * answer: the symbols for the site the element was written at, in the words * `xmd syntax` prints. Three things follow, and every case here is about one of * them. * * **The name is the engine's.** A repository `Syntax.md`, a bundled `Syntax`, an * ordinary or reserved registration, a host declaration, import middleware and a - * definition from a second loaded copy can none of them answer for it. A catalog - * anything in the run could answer for describes nothing. + * definition from a second loaded copy can none of them answer for it. Symbols + * anything in the run could answer for describe nothing. * - * **The answer is the site's.** The observation is built from the selection + * **The answer is the site's.** The reference is built from the selection * inputs the execution captured before any installation, middleware or document * code ran, and it travels lexically on canonical core's own expansion * authority — not through a context, where a name is not a secret. * - * **One occurrence observes once.** It claims the identity this execution - * minted, records exactly `{ catalog }`, and a continuation hands that back + * **One occurrence renders once.** It claims the identity this execution + * minted, records exactly `{ symbols }`, and a continuation hands that back * without consulting the filesystem, the registry, the bundle or the host again. * * Protection is about the answer, not about power: the component receives one - * operation that observes catalog text and nothing else, and a catalog naming a - * component is not permission to run it. + * reference that renders symbol text and nothing else, and naming a component in + * the symbols is not permission to run it. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -37,7 +37,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { API, useHostFiles } from "@executablemd/runtime"; -import { Component } from "../src/component-api.ts"; +import { Component, content } from "../src/component-api.ts"; import { collect } from "../src/collect.ts"; import { execute } from "../src/execute.ts"; import { executeInstalled, sourceDigest } from "../host.ts"; @@ -49,13 +49,14 @@ import { selectComponent } from "../src/components/select.ts"; import { installedBundle } from "../src/components/bundle.ts"; import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts"; -import { fixedCatalogObservation, rootCatalogObservation } from "../src/syntax-observation.ts"; -import type { CatalogObservation } from "../src/syntax-observation.ts"; +import { syntaxReference, rootSyntaxReference } from "../src/syntax-reference.ts"; +import type { SyntaxReference } from "../src/syntax-reference.ts"; +import { capturedDocumentation, contributeDocumentation } from "../src/documentation-api.ts"; import { executeReadingAssetsWith } from "../src/execute.ts"; import type { DocumentationContribution } from "../src/component-documentation.ts"; import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; -import type { ComponentOrigin, FunctionComponent, SyntaxCatalog } from "../mod.ts"; +import type { ComponentOrigin, FunctionComponent, SyntaxSymbols } from "../mod.ts"; /** An origin a catalog *component* entry can carry — everything but structural. */ type NamedOrigin = Exclude; @@ -64,11 +65,11 @@ const ROOT_PATH = "documents/root.md"; /** The approved description, spelled here so a change to it fails a test. */ const DESCRIPTION = - "Inspect components and control-flow constructs. `` renders the current " + - 'catalog; `` renders selected documentation.'; + "Inspect available components and control-flow constructs. `` lists the " + + 'symbols available here; `` renders selected documentation.'; /** A catalog with one built-in entry per name, for a case that needs a marker. */ -function catalogOf(...names: readonly string[]): SyntaxCatalog { +function catalogOf(...names: readonly string[]): SyntaxSymbols { return { version: 2, categories: [ @@ -94,10 +95,10 @@ function catalogOf(...names: readonly string[]): SyntaxCatalog { } /** A host that states the catalog its profile describes, and counts the asks. */ -function stating(catalog: SyntaxCatalog, calls: { count: number } = { count: 0 }) { +function stating(catalog: SyntaxSymbols, calls: { count: number } = { count: 0 }) { const installation: ExecutionInstallation = { // deno-lint-ignore require-yield - *catalog(): Operation { + *symbols(): Operation { calls.count += 1; return catalog; }, @@ -105,6 +106,26 @@ function stating(catalog: SyntaxCatalog, calls: { count: number } = { count: 0 } return { installation, calls }; } +/** + * A package of this suite's own, contributing documentation for ``. + * + * A name core does not ship, so a case about contribution is not also a case + * about colliding with core's real documentation. + */ +function useMarkerDocumentation(asset = "packages/test/src/components.md"): Operation { + // deno-lint-ignore require-yield + return contributeDocumentation(function* () { + return { + source: { + owner: "@executablemd/test", + asset, + text: "## Marker\n\nMARKER PROSE.\n", + }, + supplies: new Set(["Marker"]), + }; + }); +} + /** Run one root, with whatever installations the case supplies. */ function run( source: string, @@ -132,10 +153,10 @@ function* refusal(operation: Operation): Operation { throw new Error("expected the operation to be refused"); } -/** Every retained catalog observation, in order. */ +/** Every retained catalog reference, in order. */ function observations(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( - (event) => event.type === "yield" && event.description.type === "syntax_catalog", + (event) => event.type === "yield" && event.description.type === "syntax_symbols", ); } @@ -164,7 +185,7 @@ function* continuing(stream: InMemoryStream): Operation { return partial; } -/** The same history with one retained observation replaced. */ +/** The same history with one retained reference replaced. */ function* tampered( stream: InMemoryStream, replace: (value: Json) => Json, @@ -176,7 +197,7 @@ function* tampered( } if ( event.type === "yield" && - event.description.type === "syntax_catalog" && + event.description.type === "syntax_symbols" && event.result.status === "ok" ) { yield* partial.append({ @@ -191,7 +212,7 @@ function* tampered( } /** A catalog holding one component entry of exactly this identity. */ -function catalogNamed(name: string, origin: NamedOrigin): SyntaxCatalog { +function catalogNamed(name: string, origin: NamedOrigin): SyntaxSymbols { return { version: 2, categories: [ @@ -219,16 +240,21 @@ function catalogNamed(name: string, origin: NamedOrigin): SyntaxCatalog { } /** - * The observation an ordinary root carries. + * The reference an ordinary root carries. * * Built the way an execution builds it — from captured selection inputs, with * no host contribution — so a case about narrowing is about the object the * product actually hands to expansion. */ -function rootObservation(): CatalogObservation { - return rootCatalogObservation( +function* rootObservation(): Operation { + return rootSyntaxReference( { includes: [], registry: new Map(), components: [], declarations: [] }, undefined, + // What canonical execution hands it: whatever the scope bootstrapped, + // terminating in core's own. Passing nothing here would leave the reference + // with no index at all, and a case about narrowing would then be reading a + // fallback sentence rather than real documentation. + yield* capturedDocumentation(), ); } @@ -278,7 +304,7 @@ describe("Tier SYN — what one occurrence answers", () => { expect(String(bare)).toBe(renderSyntaxMarkdown(catalog)); }); - it("SYN3: a paired spelling and an authored prop refuse before any observation", function* () { + it("SYN3: a paired spelling and an authored prop refuse before any reference", function* () { const paired = stating(catalogOf("Marker")); expect(yield* refusal(run("content\n", [paired.installation]))).toContain( "written self-closing", @@ -408,16 +434,17 @@ describe("Tier SYN — the named form", () => { }); }); - it("SYN46: a cancelled named observation tears down and commits nothing", function* () { + it("SYN46: cancelling documentation collection tears down and commits nothing", function* () { const torn: string[] = []; const stream = new InMemoryStream(); - // Suspended inside *documentation-index construction*, not inside catalog - // discovery. By the time this runs the catalog is built, the occurrence is - // claimed and the durable operation is open, and the named lookup is - // reading the packaged asset — which is the window a record could be - // written in, and is reachable only from inside the documentation work. - // A lookup that skipped index construction would never enter it at all. + // Suspended inside *documentation collection*, which is where the packaged + // asset is read: once, at the execution's own boundary, after the trusted + // host bootstrapped and before the root import. So this is the window + // between an execution having begun and any element of its document having + // run, and what it rules out is a teardown that leaves the read hanging or + // a partial record behind. The occurrence-level window is SYN22's. + // // The reader belongs to *this* execution, handed to it at construction. // Nothing module-scoped: a second execution in this process reads through // its own, which SYN48 below is about. @@ -444,29 +471,29 @@ describe("Tier SYN — the named form", () => { ), ); }); - // Let the lookup get inside the index before cancelling it. + // Let collection get inside the read before cancelling it. yield* sleep(20); yield* task.halt(); }); const events = yield* stream.readAll(); - // Reached the work, then tore it down — in that order. Cancelling before - // the observation was entered would leave `entered` absent, which is the - // vacuous pass this ordering rules out. + // Reached the read, then tore it down — in that order. Cancelling before + // the read was entered would leave `entered` absent, which is the vacuous + // pass this ordering rules out. expect(torn).toEqual(["entered", "torn down"]); - // Nothing was committed at all: a durable operation records its event when - // it completes, and this one never did. So there is no record for a - // continuation to restore, successful or otherwise. + // And nothing was committed at all. The document never expanded, so no + // occurrence claimed an identity and no durable operation opened: there is + // no record for a continuation to restore, successful or otherwise. expect(observations(events)).toHaveLength(0); expect(retained(events)).toHaveLength(0); }); - it("SYN25g: an installation cannot rewrite its documentation from install()", function* () { - // Everything a host still holds after handing its contribution over: the - // source object, its text, and the name set. `install()` runs *after* the - // capture boundary, which is exactly the window this closes — a snapshot - // taken later, or a shallow copy of the array, would serve whatever these - // say by the time a document asks. + it("SYN25g: collection snapshots a contribution by value", function* () { + // Everything a bootstrap still holds after its contribution is collected: + // the source object, its text, and the name set. Collection snapshots field + // by field, which is exactly the window this closes — a shallow copy of the + // array would serve whatever these say by the time a document asks. + // // A package of its own, so this is about capture rather than about // colliding with core's real documentation of the same name. const supplies = new Set(["Marker"]); @@ -476,33 +503,199 @@ describe("Tier SYN — the named form", () => { text: "## Marker\n\nTHE CAPTURED PROSE.\n", }; - const installation: ExecutionInstallation = { - components: [], - documentation: [{ source, supplies }], + const captured = yield* scoped(function* () { // deno-lint-ignore require-yield + yield* contributeDocumentation(function* () { + return { source, supplies }; + }); + const collected = yield* capturedDocumentation(); + // Rewritten *after* the collector returned, which is the whole window: a + // reference built from this snapshot must not see any of it. + source.text = "## Marker\n\nSUBSTITUTED AFTER COLLECTION.\n"; + source.owner = "@executablemd/impostor"; + supplies.add("Substituted"); + supplies.delete("Marker"); + return collected; + }); + + const mine = captured.find((one) => one.source.asset.startsWith("packages/test/")); + if (mine === undefined) { + throw new Error("the collector did not take this bootstrap's contribution"); + } + expect(mine.source.text).toContain("THE CAPTURED PROSE."); + expect(mine.source.text).not.toContain("SUBSTITUTED AFTER COLLECTION"); + expect(mine.source.owner).toBe("@executablemd/test"); + expect([...mine.supplies]).toEqual(["Marker"]); + + // And the snapshot renders that way through the reference an execution + // builds from it, rather than only reading that way as a value. + const rendered = yield* syntaxReference( + catalogOf("Marker"), + catalogOf("Marker"), + captured, + ).documentation(["Marker"]); + expect(rendered).toContain("THE CAPTURED PROSE."); + expect(rendered).not.toContain("SUBSTITUTED AFTER COLLECTION"); + }); + + it("SYN25h: documentation arrives with the bootstrap that registers, or not at all", function* () { + // The whole point of one call: registrations and documentation arrive + // together or not at all. Two lists is what let a nested run register + // `` and then report it undocumented — a component it can run, + // described as undocumented. + const { installation: marker } = stating(catalogOf("Marker")); + const without = String(yield* scoped(() => run('\n', [marker]))); + // The component is there — the profile states it — and the prose is not. + expect(without).toContain("### ``"); + expect(without).toContain("No long-form documentation is available"); + expect(without).not.toContain("MARKER PROSE."); + + // Entered, the same site answers with the prose instead — *and* core's own + // documentation is still there beside it. A wrapper that returned its own + // contribution instead of appending to what it composed over would pass the + // first assertion and lose the terminal, which is the whole reason the + // chain delegates. + // Core's own ``, at core's own identity, beside this suite's + // ``: the index joins on name *and* origin, so an `Elicit` entry + // carrying this suite's origin would find no core documentation whether the + // terminal survived the chain or not. + const elicit = catalogNamed("Elicit", { + kind: "registered", + origin: "@executablemd/core", + reserved: false, + }); + const marked = catalogOf("Marker"); + const pair: SyntaxSymbols = { + version: 2, + categories: [ + { kind: "structural", entries: [] }, + { + kind: "built-in", + entries: [...marked.categories[1].entries, ...elicit.categories[1].entries], + }, + { kind: "user-provided", entries: [] }, + ], + }; + const both = String( + yield* scoped(function* () { + yield* useMarkerDocumentation(); + return yield* run('\n', [ + stating(pair).installation, + ]); + }), + ); + expect(both).toContain("MARKER PROSE."); + expect(both).toContain("Asks a person a structured question"); + expect(both).not.toContain("No long-form documentation is available"); + }); + + it("SYN25k: middleware a running document installs reaches nothing", function* () { + // The ordering half of the contract. Collection happens after the trusted + // host's bootstrap and *before* the root import, so a component that + // composes around the Api while the document is running composes into a + // chain nothing reads again. Otherwise a document could describe a + // component to the next agent however it liked. + const { installation: marker } = stating(catalogOf("Marker")); + const planted: ExecutionInstallation = { *install(): Operation { - source.text = "## Marker\n\nSUBSTITUTED FROM INSTALL.\n"; - source.owner = "@executablemd/impostor"; - supplies.add("Substituted"); - supplies.delete("Marker"); + yield* registerComponents([ + { + name: "Plant", + origin: "@executablemd/test", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + yield* contributeDocumentation( + // deno-lint-ignore require-yield + function* () { + return { + source: { + owner: "@executablemd/test", + asset: "packages/test/src/planted.md", + text: "## Marker\n\nPLANTED BY THE DOCUMENT.\n", + }, + supplies: new Set(["Marker"]), + }; + }, + ); + // The occurrence renders *inside* this scope, which is the only + // arrangement that tests anything: a sibling element would find + // this middleware already gone and pass however late collection + // happened. + return `planted\n\n${yield* content()}`; + }, + }, + ]); }, }; + const output = String( + yield* scoped(function* () { + yield* useMarkerDocumentation(); + return yield* run('\n\n\n', [marker, planted]); + }), + ); + // The component ran, so the plant is not being reported absent by accident. + expect(output).toContain("planted"); + // And what the occurrence renders is what the host bootstrapped. Had the + // document's contribution been read, this would either say so or refuse as + // a duplicate — either way, not this. + expect(output).toContain("MARKER PROSE."); + expect(output).not.toContain("PLANTED BY THE DOCUMENT"); + }); + + it("SYN25i: two contributions for one component refuse, whichever order", function* () { + // Order decides how the list reads and nothing else. A later contribution + // silently winning would make what a document is told about a component + // depend on the order its host happened to bootstrap packages in. + const { installation: marker } = stating(catalogOf("Marker")); + const orders: string[] = []; + for (const [first, second] of [ + ["packages/one/components.md", "packages/two/components.md"], + ["packages/two/components.md", "packages/one/components.md"], + ]) { + orders.push( + yield* refusal( + scoped(function* () { + yield* useMarkerDocumentation(first); + yield* useMarkerDocumentation(second); + return yield* run('\n', [marker]); + }), + ), + ); + } + for (const refused of orders) { + expect(refused).toContain("contributes documentation for Marker from both"); + } + // Both orders refuse, and each names the pair it saw rather than one fixed + // winner: a refusal that reported the same asset either way would be + // consistent with a chain that had picked a winner and then complained. + expect(orders[0]).not.toBe(orders[1]); + }); + + it("SYN25j: two scopes each read their own contributions", function* () { + // A contribution belongs to the scope that installed it, because that is + // what an Api answer belongs to. Two executions assembled in sibling scopes + // must not read through each other's. const { installation: marker } = stating(catalogOf("Marker")); - const rendered = String(yield* run('\n', [marker, installation])); + const inside = String( + yield* scoped(function* () { + yield* useMarkerDocumentation(); + return yield* run('\n', [marker]); + }), + ); + expect(inside).toContain("MARKER PROSE."); - // The prose captured before `install()` ran, and none of what it wrote. - expect(rendered).toContain("THE CAPTURED PROSE."); - expect(rendered).not.toContain("SUBSTITUTED FROM INSTALL"); - // And the coverage it was captured with: adding a name afterwards neither - // demands documentation for it nor refuses the index. - expect(rendered).not.toContain("Substituted"); + // The sibling scope installed nothing, so it has nothing — and the first + // scope's contribution did not outlive it. + const outside = String(yield* scoped(() => run('\n', [marker]))); + expect(outside).toContain("No long-form documentation is available"); + expect(outside).not.toContain("MARKER PROSE."); }); - it("SYN48: an ordinary observation is unaffected by another execution's suspended one", function* () { - // Two executions overlapping in one process. One is stopped inside - // documentation-index construction; the other is ordinary and must read - // canonical documentation and finish on its own. + it("SYN48: an ordinary reference is unaffected by another execution's suspended one", function* () { + // Two executions overlapping in one process. One is stopped inside its own + // documentation collection; the other is ordinary and must read canonical + // documentation and finish on its own. // // This is what a module-scoped reader gets wrong: one variable shared by // every execution means the suspended one's substitution is what the @@ -552,7 +745,7 @@ describe("Tier SYN — the named form", () => { const calls = { count: 0 }; const moving: ExecutionInstallation = { // deno-lint-ignore require-yield - *catalog(): Operation { + *symbols(): Operation { calls.count += 1; return catalogOf(`Marker${calls.count}`); }, @@ -603,10 +796,10 @@ describe("Tier SYN — the named form", () => { const record = records[0]; const value = record?.type === "yield" && record.result.status === "ok" ? record.result.value : undefined; - expect(Object.keys(value as object)).toEqual(["catalog"]); + expect(Object.keys(value as object)).toEqual(["symbols"]); // The component's own return, which the document then renders — so the two // differ by the trailing newline presentation adds, and nothing else. - expect(String((value as { catalog: string }).catalog).trim()).toBe(first.trim()); + expect(String((value as { symbols: string }).symbols).trim()).toBe(first.trim()); // A continuation hands the same text back. The documentation asset is not // reread and the catalog is not rebuilt: what an agent was shown is what it @@ -617,9 +810,9 @@ describe("Tier SYN — the named form", () => { expect(resumed).toBe(first); // And a record this version cannot read refuses rather than inventing one. - const corrupted = yield* tampered(stream, () => ({ catalog: "x", extra: 1 })); + const corrupted = yield* tampered(stream, () => ({ symbols: "x", extra: 1 })); const refused = yield* refusal(run('\n', [], corrupted)); - expect(refused).toContain("not a catalog this version can read"); + expect(refused).toContain("not a record this version can read"); }); it("SYN31: refuses an unusable list before observing anything", function* () { @@ -958,8 +1151,8 @@ describe("Tier SYN — what the chain may and may not do", () => { expect(calls.count).toBe(0); }); - it("SYN15: a document-authored context and a look-alike observation change nothing", function* () { - // Nothing a document writes reaches the observation: it is not addressed by + it("SYN15: a document-authored context and a look-alike reference change nothing", function* () { + // Nothing a document writes reaches the reference: it is not addressed by // name. The strongest thing an authored document can do is register and // bind, and the catalog is unchanged by both. const { installation } = stating(catalogOf("Marker")); @@ -1111,16 +1304,16 @@ describe("Tier SYN — the site the catalog describes", () => { }); describe("Tier SYN — the record one occurrence keeps", () => { - it("SYN19: the retained payload is closed on exactly { catalog }", function* () { + it("SYN19: the retained payload is closed on exactly { symbols }", function* () { const stream = new InMemoryStream(); yield* run("\n", [stating(catalogOf("Marker")).installation], stream); - const [observation] = observations(yield* stream.readAll()); - if (observation?.type !== "yield" || observation.result.status !== "ok") { - throw new Error("the run retained no catalog observation"); + const [reference] = observations(yield* stream.readAll()); + if (reference?.type !== "yield" || reference.result.status !== "ok") { + throw new Error("the run retained no syntax record"); } - const value = Object(observation.result.value); - expect(Object.keys(value)).toEqual(["catalog"]); - expect(typeof value.catalog).toBe("string"); + const value = Object(reference.result.value); + expect(Object.keys(value)).toEqual(["symbols"]); + expect(typeof value.symbols).toBe("string"); }); it("SYN20: a continuation restores the catalog after the environment moves, and asks nothing", function* () { @@ -1134,7 +1327,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { // contribution refuses to answer at all. const moved: ExecutionInstallation = { // deno-lint-ignore require-yield - *catalog(): Operation { + *symbols(): Operation { throw new Error("the continuation rebuilt the catalog"); }, }; @@ -1143,7 +1336,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { expect(continued).not.toContain("### ``"); // A fresh execution sees the moved environment, which is what shows the - // restoration above was retention rather than the observation being inert. + // restoration above was retention rather than the reference being inert. expect( String(yield* run("\n", [stating(catalogOf("After")).installation])), ).toContain("### ``"); @@ -1153,7 +1346,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { const cases: [string, (value: Json) => Json][] = [ ["the member is missing", () => ({})], ["an unknown member was added", (value) => ({ ...Object(value), extra: true })], - ["the member has the wrong type", () => ({ catalog: 7 })], + ["the member has the wrong type", () => ({ symbols: 7 })], ]; for (const [, replace] of cases) { const first = new InMemoryStream(); @@ -1166,15 +1359,15 @@ describe("Tier SYN — the record one occurrence keeps", () => { hostile, ), ); - expect(refused).toContain("is not a catalog this version can read"); + expect(refused).toContain("is not a record this version can read"); } }); - it("SYN22: a cancelled observation tears down and commits no catalog", function* () { + it("SYN22: a cancelled reference tears down and commits no catalog", function* () { const teardown: string[] = []; const stream = new InMemoryStream(); const hanging: ExecutionInstallation = { - *catalog(): Operation { + *symbols(): Operation { yield* ensure(function* () { teardown.push("released"); }); @@ -1187,7 +1380,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { const running = yield* spawn(function* () { yield* run("\n", [hanging], stream); }); - // Long enough for the observation to be entered and suspended. + // Long enough for the reference to be entered and suspended. yield* sleep(20); yield* running.halt(); }); @@ -1201,7 +1394,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { }); }); -describe("Tier SYN — observation is never authority", () => { +describe("Tier SYN — reference is never authority", () => { it("SYN23: a catalog naming a component neither registers nor resolves it", function* () { // The strongest form: the trusted host itself states a catalog naming a // component nothing supplies. @@ -1224,7 +1417,7 @@ describe("Tier SYN — observation is never authority", () => { expect(entry?.forms).toEqual(["self-closing"]); expect(entry?.returnMode).toBe("text"); // One optional prop, closed: `names` selects documentation, and anything - // else is refused before an observation. + // else is refused before an reference. expect(entry?.props).toEqual({ type: "object", properties: { @@ -1234,8 +1427,8 @@ describe("Tier SYN — observation is never authority", () => { minItems: 1, uniqueItems: true, description: - "Optional. Render these components' catalog metadata and long-form documentation " + - "instead of the compact catalog. Entries render once each, in catalog order.", + "Optional. Render these components' metadata and long-form documentation " + + "instead of the list of available symbols. Entries render once each, in symbol order.", }, }, additionalProperties: false, @@ -1268,60 +1461,62 @@ describe("Tier SYN — observation is never authority", () => { * The seam a trusted evaluation boundary narrows through. * * `` admits an exact vocabulary before it expands a generated - * fragment, and the observation it installs for that subtree is that + * fragment, and the reference it installs for that subtree is that * admission's own catalog — it cannot add an entry the admission does not * hold, because it is handed the catalog rather than asked to build one. - * Installing it for an evaluation subtree is #713's; that the observation is + * Installing it for an evaluation subtree is #713's; that the reference is * the catalog and nothing more is this. */ - it("SYN25b: a narrowed observation answers with exactly the catalog it was given", function* () { + it("SYN25b: a narrowed reference answers with exactly the catalog it was given", function* () { const narrowed = catalogOf("Admitted"); - const observation = fixedCatalogObservation(narrowed); - expect(yield* observation.observe()).toBe(renderSyntaxMarkdown(narrowed)); + const reference = syntaxReference(narrowed); + expect(yield* reference.symbols()).toBe(renderSyntaxMarkdown(narrowed)); // Nothing of the enclosing site leaks into it: a name the wider profile has // is absent, because the catalog it was handed does not hold one. - expect(yield* observation.observe()).not.toContain("### ``"); + expect(yield* reference.symbols()).not.toContain("### ``"); }); /** * The seam #713 installs through, proved without an ``. * - * A narrowing boundary hands the observation two catalogs: what may execute + * A narrowing boundary hands the reference two catalogs: what may execute * in the subtree, and the enclosing authoring catalog selection reads from. * Everything below is about them being genuinely two. */ - it("SYN25c: a narrowed observation documents the enclosing site and marks availability", function* () { + it("SYN25c: a narrowed reference documents the enclosing site and marks availability", function* () { const enclosing = catalogOf("Admitted", "Withheld"); const narrowed = catalogOf("Admitted"); - const observation = fixedCatalogObservation(narrowed, enclosing); + const reference = syntaxReference(narrowed, enclosing); // What may execute here is the narrowed catalog, and the bare form reports // exactly that. - const available = yield* observation.observe(); + const available = yield* reference.symbols(); expect(available).toContain("### ``"); expect(available).not.toContain("### ``"); // Reference material comes from the enclosing catalog, so a component this // subtree may not run can still be explained — and the entry says so // rather than leaving a reader to assume they have both. - const documented = yield* observation.document(["Withheld"]); + const documented = yield* reference.documentation(["Withheld"]); expect(documented).toContain("### ``"); expect(documented).toContain("**Available in this evaluation:** no"); // And one that is admitted reports the other answer, so the field is // discriminating rather than a constant. - const admitted = yield* observation.document(["Admitted"]); + const admitted = yield* reference.documentation(["Admitted"]); expect(admitted).toContain("**Available in this evaluation:** yes"); // A boundary that narrows nothing has one catalog, and everything in it is // available — the ordinary case. - const open = fixedCatalogObservation(enclosing); - expect(yield* open.document(["Withheld"])).toContain("**Available in this evaluation:** yes"); + const open = syntaxReference(enclosing); + expect(yield* open.documentation(["Withheld"])).toContain( + "**Available in this evaluation:** yes", + ); }); it("SYN25d: availability compares the whole identity, not the spelling", function* () { /** One catalog holding a single entry of exactly this identity. */ - const holding = (origin: NamedOrigin): SyntaxCatalog => ({ + const holding = (origin: NamedOrigin): SyntaxSymbols => ({ version: 2, categories: [ { kind: "structural", entries: [] }, @@ -1346,14 +1541,14 @@ describe("Tier SYN — observation is never authority", () => { ], }); - const reference: NamedOrigin = { + const authored: NamedOrigin = { kind: "registered", origin: "@executablemd/core", reserved: false, }; // Each of these is a *different component* that happens to be spelled - // `Elicit`. Reporting the reference entry as available because something of + // `Elicit`. Reporting the authoring entry as available because something of // that name can run would tell an author they may execute what they were // just shown. const impostors: Record = { @@ -1381,8 +1576,8 @@ describe("Tier SYN — observation is never authority", () => { }; for (const [what, origin] of Object.entries(impostors)) { - const observation = fixedCatalogObservation(holding(origin), holding(reference)); - const rendered = yield* observation.document(["Elicit"]); + const nested = syntaxReference(holding(origin), holding(authored)); + const rendered = yield* nested.documentation(["Elicit"]); expect([what, rendered.includes("**Available in this evaluation:** no")]).toEqual([ what, true, @@ -1399,40 +1594,40 @@ describe("Tier SYN — observation is never authority", () => { }; const moved: NamedOrigin = { ...bundled, sourceHash: "c".repeat(40) }; expect( - yield* fixedCatalogObservation(holding(moved), holding(bundled)).document(["Elicit"]), + yield* syntaxReference(holding(moved), holding(bundled)).documentation(["Elicit"]), ).toContain("**Available in this evaluation:** no"); // The positive control: one exact identity, admitted. expect( - yield* fixedCatalogObservation(holding(reference), holding(reference)).document(["Elicit"]), + yield* syntaxReference(holding(authored), holding(authored)).documentation(["Elicit"]), ).toContain("**Available in this evaluation:** yes"); }); - it("SYN25e: a narrowed observation is derived from the enclosing one", function* () { + it("SYN25e: a narrowed reference is derived from the enclosing one", function* () { // The seam as an evaluator actually meets it: it holds the enclosing - // observation and an admitted catalog, and nothing else. No raw + // reference and an admitted catalog, and nothing else. No raw // contribution list, no second index — which is the point, because that // list is execution-private and rebuilding an index from it is how two // indexes drift apart. - const enclosing = rootObservation(); + const enclosing = yield* rootObservation(); const admitted = catalogOf("Admitted"); - const narrowed = enclosing.narrow(admitted); + const narrowed = enclosing.available(admitted); // What may run is the admission. - const executable = yield* narrowed.observe(); + const executable = yield* narrowed.symbols(); expect(executable).toContain("### ``"); expect(executable).not.toContain("### ``"); // What may be read about is still the enclosing site's, with the enclosing // index behind it — so a real component's real documentation survives. - const documented = yield* narrowed.document(["Elicit"]); + const documented = yield* narrowed.documentation(["Elicit"]); expect(documented).toContain("### ``"); expect(documented).toContain("Asks a person a structured question"); expect(documented).toContain("**Available in this evaluation:** no"); - // And the enclosing observation is unchanged by having been narrowed. - expect(yield* enclosing.observe()).toContain("### ``"); - expect(yield* enclosing.document(["Elicit"])).toContain( + // And the enclosing reference is unchanged by having been narrowed. + expect(yield* enclosing.symbols()).toContain("### ``"); + expect(yield* enclosing.documentation(["Elicit"])).toContain( "**Available in this evaluation:** yes", ); }); @@ -1445,7 +1640,7 @@ describe("Tier SYN — observation is never authority", () => { text: "## Alpha\n\nThe captured documentation.\n", }; const contribution = { source, supplies }; - const observation = fixedCatalogObservation( + const reference = syntaxReference( catalogNamed("Alpha", { kind: "registered", origin: "@executablemd/mutable", @@ -1461,12 +1656,12 @@ describe("Tier SYN — observation is never authority", () => { supplies.add("Beta"); supplies.delete("Alpha"); - const rendered = yield* observation.document(["Alpha"]); + const rendered = yield* reference.documentation(["Alpha"]); expect(rendered).toContain("The captured documentation."); expect(rendered).not.toContain("SUBSTITUTED AFTER CAPTURE"); }); - it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () { + it("SYN25: an execution that carries no reference refuses rather than inventing one", function* () { // `execute()` driven directly still carries one, so the case that has none // is an expansion driven outside an execution — which is what a component // reaching for a catalog with nothing established would meet. diff --git a/packages/testing/mod.ts b/packages/testing/mod.ts index e36976fc..09fb77e8 100644 --- a/packages/testing/mod.ts +++ b/packages/testing/mod.ts @@ -64,6 +64,7 @@ export { installTestingComponents, TESTING_REGISTRATIONS, testingDocumentation, + useTestingComponents, } from "./src/components.ts"; export { useTesting } from "./src/use-testing.ts"; // The nested-execution harness. This package owns the authored components and diff --git a/packages/testing/src/components.ts b/packages/testing/src/components.ts index ddfb1651..2b676695 100644 --- a/packages/testing/src/components.ts +++ b/packages/testing/src/components.ts @@ -33,6 +33,7 @@ import { Err } from "effection"; import type { Operation } from "effection"; import { Component, + contributeDocumentation, documented, packageDocumentation, registerComponents, @@ -44,6 +45,7 @@ import type { ComponentFailure, ComponentRegistration, DocumentationContribution, + DocumentationReader, DocumentExecution, } from "@executablemd/core"; import { boundary, record, Test, testing, TestFailureError } from "./test-api.ts"; @@ -107,14 +109,30 @@ const TEST_TIMEOUT_MS = 20_000; * to that array demands a section for it rather than quietly shipping one * without. */ -export function* testingDocumentation(): Operation { +export function* testingDocumentation( + read?: DocumentationReader, +): Operation { return yield* packageDocumentation( new URL("./components.md", import.meta.url), { owner: TESTING_ORIGIN, asset: "packages/testing/src/components.md" }, TESTING_REGISTRATIONS.map((registration) => registration.name), + read, ); } +/** + * This package's vocabulary, as declarations and nothing else. + * + * Registrations and the documentation that describes them, installed together + * so a scope that has one has the other. `xmd syntax` enters exactly this and + * stops: describing an environment installs no behavior chain, no activation + * guard and no execution middleware. + */ +export function* useTestingComponents(): Operation { + yield* registerComponents(TESTING_REGISTRATIONS); + yield* contributeDocumentation(testingDocumentation); +} + export const TESTING_REGISTRATIONS: readonly ComponentRegistration[] = [ // Non-reserved defaults: a repository component of any of these names is // chosen ahead of them, as it would be ahead of any other package's. @@ -241,7 +259,7 @@ export function* installHandlers( }, }); - yield* registerComponents(TESTING_REGISTRATIONS); + yield* useTestingComponents(); yield* Execution.around({ *execute([request], next) { // Fresh boundary collection per execution: outcomes reported by diff --git a/packages/web/mod.ts b/packages/web/mod.ts index 4587ab03..92277e52 100644 --- a/packages/web/mod.ts +++ b/packages/web/mod.ts @@ -14,7 +14,12 @@ * printed first and the form keeps waiting either way. */ -export { installWebComponents, WEB_REGISTRATIONS, webDocumentation } from "./src/components.ts"; +export { + installWebComponents, + useWebComponents, + WEB_REGISTRATIONS, + webDocumentation, +} from "./src/components.ts"; export { installWebElicitation } from "./src/elicitation.ts"; export { liveForm } from "./src/live-form.ts"; export type { LiveFormInput } from "./src/live-form.ts"; diff --git a/packages/web/src/components.ts b/packages/web/src/components.ts index e51360af..292e9db2 100644 --- a/packages/web/src/components.ts +++ b/packages/web/src/components.ts @@ -11,8 +11,17 @@ * act — `installWebElicitation()` — and nothing about it is component metadata. */ -import { documented, packageDocumentation, registerComponents } from "@executablemd/core"; -import type { ComponentRegistration, DocumentationContribution } from "@executablemd/core"; +import { + contributeDocumentation, + documented, + packageDocumentation, + registerComponents, +} from "@executablemd/core"; +import type { + ComponentRegistration, + DocumentationContribution, + DocumentationReader, +} from "@executablemd/core"; import type { Operation } from "effection"; import { WEB_FORM_PROPS, WEB_FORM_RETURNS, WebForm } from "./WebForm.ts"; @@ -20,11 +29,14 @@ import { WEB_FORM_PROPS, WEB_FORM_RETURNS, WebForm } from "./WebForm.ts"; export const WEB_ORIGIN = "@executablemd/web"; /** This package's long-form documentation, derived from its registrations. */ -export function* webDocumentation(): Operation { +export function* webDocumentation( + read?: DocumentationReader, +): Operation { return yield* packageDocumentation( new URL("./components.md", import.meta.url), { owner: WEB_ORIGIN, asset: "packages/web/src/components.md" }, WEB_REGISTRATIONS.map((registration) => registration.name), + read, ); } @@ -47,6 +59,18 @@ export const WEB_REGISTRATIONS: readonly ComponentRegistration[] = [ }, ]; -export function* installWebComponents(): Operation { +/** + * This package's vocabulary, as declarations and nothing else. + * + * Registrations and the documentation that describes them, installed together + * so a scope that has one has the other. `xmd syntax` enters exactly this; + * installing the elicitation provider stays a separate, operational act. + */ +export function* useWebComponents(): Operation { yield* registerComponents(WEB_REGISTRATIONS); + yield* contributeDocumentation(webDocumentation); +} + +export function* installWebComponents(): Operation { + yield* useWebComponents(); } diff --git a/packages/workflow/src/composition/installation.ts b/packages/workflow/src/composition/installation.ts index 6e0b980d..4d33dc6d 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/workflow/src/composition/installation.ts @@ -22,12 +22,17 @@ import type { Operation } from "effection"; import { + contributeDocumentation, documented, formDispatcher, packageDocumentation, registerComponents, } from "@executablemd/core"; -import type { ComponentRegistration, DocumentationContribution } from "@executablemd/core"; +import type { + ComponentRegistration, + DocumentationContribution, + DocumentationReader, +} from "@executablemd/core"; import { COMPOSITION_ORIGIN, dirDefinition } from "./definitions.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; @@ -66,7 +71,9 @@ const dir = dirDefinition(); * registered from a definition rather than spelled inline, and would be the * easiest one to leave undocumented if this list were maintained by hand. */ -export function* compositionDocumentation(): Operation { +export function* compositionDocumentation( + read?: DocumentationReader, +): Operation { return yield* packageDocumentation( new URL("./components.md", import.meta.url), { @@ -74,6 +81,7 @@ export function* compositionDocumentation(): Operation registration.name), + read, ); } @@ -229,7 +237,16 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ }, ]; -/** Register the composition vocabulary as ordinary defaults for this scope. */ -export function useCompositionComponents(): Operation { - return registerComponents(COMPOSITION_REGISTRATIONS); +/** + * Register the composition vocabulary as ordinary defaults for this scope. + * + * The registrations and the documentation that describes them, installed + * together so a scope that has one has the other. Both are declarations: this + * installs no provider, discovers no ambient repository, acquires no lock, + * spawns no Git and reads no credential, which is what lets `xmd syntax` enter + * it to describe the profile. + */ +export function* useCompositionComponents(): Operation { + yield* registerComponents(COMPOSITION_REGISTRATIONS); + yield* contributeDocumentation(compositionDocumentation); } diff --git a/scripts/validate-documentation.ts b/scripts/validate-documentation.ts index fee30436..350444bf 100644 --- a/scripts/validate-documentation.ts +++ b/scripts/validate-documentation.ts @@ -7,33 +7,50 @@ * the components it documents, and the first person to notice would be an * author whose `` refused at run time. * - * So the check is the real assembly. It reads the same contributions the `run` - * profile installs, through the same loader, and builds the same index — which - * means a missing section, an unknown heading and a component documented twice - * each fail the build for exactly the reason they would fail a run. + * So the check is the real assembly. It enters the same bootstraps the `run` + * profile enters, collects through the same Api, and builds the same index — + * which means a missing section, an unknown heading and a component documented + * twice each fail the build for exactly the reason they would fail a run. */ -import { main } from "effection"; +import { main, scoped } from "effection"; import type { Operation } from "effection"; -import { documentationIndexFor } from "@executablemd/core"; -import { runProfileDocumentation } from "../packages/cli/src/syntax.ts"; +import { capturedDocumentation, documentationIndexFor } from "@executablemd/core"; +import type { ComponentOrigin, DocumentationIndex } from "@executablemd/core"; +import { useRunProfileRegistry } from "../packages/cli/src/syntax.ts"; /** Assemble the complete index, throwing whatever it refuses with. */ export function* validateDocumentation(): Operation { - const index = yield* documentationIndexFor(yield* runProfileDocumentation()); - // Read one entry back, so a build cannot pass by assembling an index that - // holds nothing: an empty set satisfies every rule above vacuously. - const sample = index.documentationFor("Syntax", { - kind: "protected", - origin: "@executablemd/core", + // Inside the bootstrap scope, because a contribution belongs to the scope + // that installed it: collecting outside would find core's terminal alone and + // pass every rule vacuously. + const index = yield* scoped(function* () { + yield* useRunProfileRegistry(); + return documentationIndexFor(yield* capturedDocumentation()); }); + // Read two entries back, one from each side of the terminal, so a build + // cannot pass by assembling an index that holds nothing: an empty set + // satisfies every rule above vacuously, and an index holding core's + // contribution alone would satisfy them for a profile that bootstrapped no + // package at all. + read(index, "Syntax", { kind: "protected", origin: "@executablemd/core" }); + read(index, "WebForm", { + kind: "registered", + origin: "@executablemd/web", + reserved: false, + }); + return 1; +} + +/** One entry the index must actually hold, read the way a document reads it. */ +function read(index: DocumentationIndex, name: string, origin: ComponentOrigin): void { + const sample = index.documentationFor(name, origin); if (sample === undefined || sample.length === 0) { throw new Error( - "the documentation index built without 's own documentation, so it is not the " + + `the documentation index built without <${name}>'s documentation, so it is not the ` + "index this product ships", ); } - return 1; } if (import.meta.main) { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index ee2a651a..e95f5523 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2678,11 +2678,11 @@ absence falls through to a default: a candidate that exists but cannot be read, imported, parsed, or compiled fails where it is loaded, so a broken local component is never quietly replaced. -#### 5.3.1 ``, the protected catalog component +#### 5.3.1 ``, the protected symbols component `` outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints. One -catalog construction and one Markdown renderer serve both, so an operator +symbol construction and one Markdown renderer serve both, so an operator printing a profile and an agent being told what to write are never given different accounts of one environment. @@ -2690,7 +2690,7 @@ different accounts of one environment. ``` -renders the catalog where it is written. It is a **text component**: the ordinary +renders the symbols available where it is written. It is a **text component**: the ordinary engine-owned `as` captures the same text and emits nothing. ```mdx @@ -2700,12 +2700,12 @@ engine-owned `as` captures the same text and emits nothing. It is **self-closing only** and declares one optional prop, `names`: a non-empty array of unique component-name strings. A paired spelling, any other prop, an empty list, a duplicate, a non-string member and a name this site has no -component for are each refused before a catalog is observed, so a refusal +component for are each refused before the symbols are read, so a refusal produces no partial text and no successful retained result. **The named form renders documentation.** `` -renders each selected component's catalog metadata followed by the long-form -documentation its owning package ships, once each, **in catalog order** whatever +renders each selected component's symbol metadata followed by the long-form +documentation its owning package ships, once each, **in symbol order** whatever order they were asked for in. `as` captures the same text in either form. Documentation joins to metadata by component name **and owning package**. Only a @@ -2741,11 +2741,26 @@ not through `API.Fs` or the document-facing `Files` authority. Both of those are middleware a running document can compose around, and a document that could answer the read would decide what the product says about itself. -**Reference and availability are separate.** The observation carries two inputs. +**Documentation composes with the components it describes.** A package +contributes the documentation for what it registers through one stable, +namespaced contextual Api, in the same bootstrap call that registers it, so a +host that bootstraps a package gets both. Canonical core is the terminal, and +every wrapper delegates before appending its own, so composition order decides +how the list reads and nothing else: two contributions naming one component of +one package refuse wherever they sat in the chain rather than the later one +winning. Canonical execution collects **once**, after the trusted host's +bootstrap and before the root import or any document code, and snapshots the +answer field by field — so middleware a running document or component installs +afterwards composes into a chain nothing reads, and two executions assembled in +separate scopes each read their own. A host-maintained list of every package's +documentation, kept beside a host-maintained list of every package's +registrations, is two lists that drift; this is one. + +**Reference and availability are separate.** The reference carries two inputs. Bare `` reports what may **execute** at this site. The named form -selects from the **enclosing authoring catalog** and states, per entry, -`**Available in this evaluation:** yes` or `no`. At a root the two are one -catalog and everything selected is available; a trusted evaluation boundary that +selects from the **enclosing authoring symbols** and states, per entry, +`**Available in this evaluation:** yes` or `no`. At a root the two are one set +of symbols and everything selected is available; a trusted evaluation boundary that narrows execution keeps the enclosing reference, so a nested author can be told how a component works where they may not run one — and is told which it is. Neither input carries definitions, import witnesses, invocation capabilities, @@ -2754,7 +2769,7 @@ providers, registrations or any other execution authority. `xmd syntax Elicit` is the same lookup — one selection, one index, one renderer — so the command and the component cannot describe one component two ways. The compact `xmd syntax` and its version-2 `--json` are unchanged: documentation is -prose rather than a catalog member, and putting it in routine output would make +prose rather than a symbol entry, and putting it in routine output would make every default Plan prompt unnecessarily large. **Canonical core owns the name.** A repository `Syntax.md`, `Syntax.ts` or @@ -2766,39 +2781,39 @@ the import, delegate it and refuse it by throwing, but cannot answer it, replace what came back, mutate it, or hand back a definition kept from another import or built by another loaded copy. -**The catalog is the execution's own.** Canonical core builds it at the root from +**The symbols are the execution's own.** Canonical core builds them at the root from the selection inputs that execution captured before any installation, middleware or document code ran — its includes, the registry it started with, the identity components and exact Markdown its host declared, and the component bundle it is -closed over — and carries it lexically on canonical core's own expansion -authority. A trusted host may state the catalog its profile describes instead, +closed over — and carries them lexically on canonical core's own expansion +authority. A trusted host may state the symbols its profile describes instead, captured on the same terms; one execution accepts one, and two are refused rather than ordered. Nothing is built until an occurrence asks. -Seeing a component in a catalog grants nothing. It neither registers, resolves +Seeing a component in the symbols grants nothing. It neither registers, resolves nor authorizes that component: what a name means is still this section's decision, and what may run is still the execution's. -**The catalog says where it came from.** A protected component is reported under -the catalog origin kind `protected`, carrying the canonical core origin — never +**The symbols say where a component came from.** A protected component is reported under +the symbol origin kind `protected`, carrying the canonical core origin — never as a reserved registration, which is a *host's* claim under a name and can be absent, replaced or refused where this cannot. A workflow-bundle member is reported under the kind `workflow`, carrying its canonical repository-relative path **and** the blob's own object id, so it stays distinguishable from a repository candidate, which is whatever that path holds now. Both kinds are -additions, so the catalog is **version 2**: a version-1 reader was promised a +additions, so the symbols are **version 2**: a version-1 reader was promised a closed set of origins, and neither emitting an unknown kind nor reusing a neighbouring one would keep that promise. Nothing else about the shape changed. **Each occurrence observes once.** It claims the durable identity the execution -minted for it, performs one `syntax_catalog` durable observation, and retains -exactly `{ catalog: string }`. On continuation that record is parsed as a closed +minted for it, performs one `syntax_symbols` durable read, and retains +exactly `{ symbols: string }`. On continuation that record is parsed as a closed protocol and returned without consulting the filesystem, the registry, the bundle, the host or the lexical observation again; a missing, additional or mistyped member is stale input and refuses before output or binding. Two authored -occurrences are two identities and two observations, repeated reads of one -binding observe nothing again, and a failed or cancelled observation completes -its teardown and commits no catalog. +occurrences are two identities and two reads, repeated reads of one +binding read nothing again, and a failed or cancelled read completes +its teardown and commits no record. #### The run profile's repository declarations @@ -2974,8 +2989,8 @@ capabilities — ``, ``, ``, `` — are the closure those exact bytes carry, and are syntax no document may write. The vocabulary the Agent is shown is not among them: the packaged bytes write the public `` (§5.3.1), whose own -`syntax_catalog` observation retains exactly `{ catalog }`, so a continuation -restores the catalog the run actually showed rather than rebuilding it, and +`syntax_symbols` read retains exactly `{ symbols }`, so a continuation +restores the symbols the run actually showed rather than rebuilding them, and `` retains exactly `{ instruction }` beside it. [The plan command](./plan-command-spec.md) is the contract. @@ -3060,7 +3075,7 @@ Its spelling is canonical and is checked where the declaration is made. Omitting it means both forms — what every registration meant before it existed — and the only arrays accepted are `["self-closing"]`, `["paired"]` and `["self-closing", "paired"]`. An empty array, a reversed pair, a repeated member -and a form no invocation has are each refused there, so a catalog can be +and a form no invocation has are each refused there, so two entries can be compared without being normalized first and a reader never has to wonder whether a different order meant something else. The identity components a host declares to an execution (§5.6) are held to the same rule, whether they are registered by @@ -3184,7 +3199,7 @@ directory — every structural construct, and the one implementation selection chooses for every other name — and returns it as a versioned value: ```typescript -interface SyntaxCatalog { +interface SyntaxSymbols { readonly version: 1; readonly categories: readonly [ { readonly kind: "structural"; readonly entries: readonly StructuralSyntaxEntry[] }, @@ -3262,7 +3277,7 @@ skipped without being read. The default includes are `["components", "."]`, and what this decides is whether describing a repository reads its `node_modules`, its `.git` and its build output before discarding every path in them. -**A partial catalog is never presented as a complete one.** A missing include +**A partial set of symbols is never presented as a complete one.** A missing include contributes nothing, exactly as it does during execution. An include that exists but cannot be enumerated fails the whole request: one that is not a directory, one that cannot be read, and one that is itself a symbolic link. A directory @@ -3301,7 +3316,7 @@ digest as its origin. Its private closure contributes nothing: those names are not syntax a document may write, so listing them would describe an environment that does not exist. -**Inspection is observation, never authority.** Building a catalog installs only +**Inspection is observation, never authority.** Building symbols installs only the declarative registration layer selection needs. It enters no execution, constructs no durable stream, installs no Files, Service, Agent or elicitation provider, starts no testing session, reserves no terminal, mints no invocation @@ -3311,15 +3326,15 @@ from that plain declaration, and its authority-bearing factory is reached only by a real execution. **`xmd syntax` is the command.** It writes deterministic Markdown by default and -the catalog above as JSON with `--json`, both from one inspection. Its only +the same symbols as JSON with `--json`, both from one inspection. Its only options are the ordered, repeatable `--include`, which defaults to `["components", "."]` and is replaced rather than extended by explicit values, and `--json`. It takes no document and no run option, because it runs nothing. An inspection failure is reported on stderr with exit status 1 and no partial -catalog on stdout. +output on stdout. -**The command has not succeeded until stdout has taken the whole catalog.** A -pipe holds far less than a catalog, so backpressure changes how long the command +**The command has not succeeded until stdout has taken every byte.** A +pipe holds far less than the whole rendering, so backpressure changes how long the command takes and never which bytes arrive: a reader that consumes slowly receives exactly what a regular-file redirect receives, in both forms. A sink that closes or refuses the write is reported on stderr with exit status 1, rather than @@ -3970,7 +3985,7 @@ of the first chunk and the end of the last are trimmed. Matching compares Unicode code points and completes in time bounded by the product of the pattern and label sizes. -A selector must resolve to exactly one catalog entry. Zero matches and several +A selector must resolve to exactly one symbol entry. Zero matches and several matches both fail. Diagnostics report canonical encoded references, so a duplicate canonical path is reported as an ambiguity rather than resolved. @@ -4253,7 +4268,7 @@ Because the record carries the content it was taken from, the selection is verified against it rather than merely parsed: a recorded exact target must still resolve to itself in the recorded content, and a recorded failure must be exactly the failure the recorded selector produces against that content. A -catalog, a match list, or a kind that the recorded document contradicts is +a catalog, a match list, or a kind that the recorded document contradicts is therefore malformed too. Reading a record is **total**, and identification is separate from reading. An @@ -4507,7 +4522,7 @@ Matched targets: `multiple-matches` lists the matches; an invalid selector and a no-match list the whole catalog under `Available targets:`, or say `The document has no -targets.` when the catalog is empty. Every other failure keeps the printed-error +targets.` when the catalog are empty. Every other failure keeps the printed-error behavior it already had. A filename containing `#` is written `%23`, and every literal `%` is written @@ -8657,7 +8672,7 @@ escaping rule or a trailing newline. The component is **self-closing only**, and that is declared to canonical dispatch rather than decided in its body (§5.6), so what runs, what the syntax -catalog advertises and what a refusal says all come from one value. Any paired +the symbols advertise and what a refusal says all come from one value. Any paired spelling — including one whose content is empty — is refused as a printed component failure naming the `` spelling, and the content written between the tags never expands. @@ -9016,7 +9031,7 @@ and what every refusal says are Markdown; the four phases it may reach — ``, ``, `` and `` — are declared to canonical execution by an eligible compiled macOS or Linux host, appear in no `xmd run` profile, repository lookup -or public catalog, and carry no contextual authority. An installation that +or public syntax symbols, and carry no contextual authority. An installation that cannot replace itself therefore has no component to reach rather than a check it could forget. The command's complete contract is [`xmd upgrade`](./upgrade-command-spec.md). @@ -9854,7 +9869,7 @@ trusted-host events may have no authored source. | Resolve components (glob) | `glob` | `resolve:{dir}` | Only when `useDurableGlobResolver` middleware is installed | | Read over HTTP | `fetch` | `fetch:{expansion id}` | Normalized request in `description.input`; status, detached headers and text body in the result (§6.18) | | Admit generated XMD | `generated_xmd` | `generated:{fragment id}` | The canonical class selection, retained roots, selected root, every selected entry as a name, identity and admitted forms, and the exact request policy in `description.input`; the admitted source, that same policy, and the identity and form of each element the fragment named in the result (workflow-workspace-spec §8.4) | -| Observe the catalog | `syntax_catalog` | `syntax_catalog:{expansion id}` | One per authored `` occurrence. The success payload is closed on exactly `{ catalog: string }` — the rendered Markdown the component returned — so a continuation restores the catalog the run actually showed without consulting the filesystem, registry, bundle, host or lexical observation again. A missing, additional or mistyped member is stale input and refuses before output or binding; a cancelled observation completes teardown and commits nothing (§5.3.1) | +| Read the symbols | `syntax_symbols` | `syntax_symbols:{expansion id}` | One per authored `` occurrence. The success payload is closed on exactly `{ symbols: string }` — the rendered Markdown the component returned — so a continuation restores the symbols the run actually showed without consulting the filesystem, registry, bundle, host or lexical observation again. A missing, additional or mistyped member is stale input and refuses before output or binding; a cancelled read completes teardown and commits nothing (§5.3.1) | ### 10.2 Example journal for a multi-component document @@ -10699,7 +10714,7 @@ platform's. Driven through `execute()` against real files, ordinary core selection and real expansion. A sibling that must not run records its own mark, so a stopped document is proved by what did not happen rather than by absent output. The -recovery boundaries themselves are Tier OM's and Tier RF's, the catalog row is +recovery boundaries themselves are Tier OM's and Tier RF's, the symbols row is Tier SY's, the loop's own bound is Tier LOOP's, and missing-`` settlement is Tier RV's; these rows cross-reference them rather than restating them. @@ -10802,7 +10817,7 @@ Each row names the derivation it kills. | CR32 | Registration replay | A reserved registration records its origin and replays | | CR33/CR34 | Origin mismatch | A recorded origin that is missing or replaced fails explicitly rather than invoking another component | -### Tier SY — The syntax catalog +### Tier SY — The syntax symbols Provider-neutral: the filesystem is stubbed at the contextual `API.Fs` boundary, so the include-boundary rows are the same on every host. Defined in §5.3. @@ -10816,10 +10831,10 @@ so the include-boundary rows are the same on every host. Defined in §5.3. | SY7c | Pruning | A lower-case, hidden or dotted directory is never read — at the top level or deeper — while the direct, nested and index candidates beside it stay discoverable; every skipped directory throws if it is read, and the recorded reads name only the ones a name reaches | | SY8–SY11 | Selection decides | Include order, `.md` before `.ts`, direct before index, registered fallback, and a repository override appearing once as user-provided | | SY12–SY18c | Include boundaries | An absent include contributes nothing; a non-directory root, a symbolic-link root, and a selection-relevant link to a directory or to nothing each fail the whole request; a relevant link to a file is selected; a link behind a lower-case, dotted or hidden prefix is ignored even beside one that is refused; and the diagnostic names the configured include and the logical entry rather than the resolved target | -| SY13b | An unreadable reachable directory | An include root that refuses, and a valid-name directory beneath it that refuses, each fail the whole request rather than shortening the catalog | +| SY13b | An unreadable reachable directory | An include root that refuses, and a valid-name directory beneath it that refuses, each fail the whole request rather than shortening the symbols | | SY13c | Include spellings | `.`, `./` and `.//` read the same directories and `./Ns` and `.//Ns` read the same directories, none of them absolute, and each spelling still selects exactly what it selects today | | SY13d | An absolute root | An include with two leading separators reads only beneath that exact prefix; the directory one separator away throws if it is read | -| SY19–SY22 | Markdown documentation | String `description`/`as`/`context` reach the catalog; a non-string value documents nothing; an undocumented component stays complete; a declared `returns` reports `value` mode with its schema | +| SY19–SY22 | Markdown documentation | String `description`/`as`/`context` reach the symbols; a non-string value documents nothing; an undocumented component stays complete; a declared `returns` reports `value` mode with its schema | | SY23 | Opaque TypeScript | A repository `.ts` entry is origin-only and carries no contract field | | SY24/SY25 | Complete contracts | ``'s two forms, ``'s one, ``'s one and its capture, and text and value return modes; declaration order of captures and forms survives, each canonical forms spelling is accepted, and every other one is refused | | SY26–SY27 | Declared components | One is described without its factory being called and a repository file still overrides it; two declarations of one name are refused before either factory; and a declaration inspection refuses is refused identically by registration | @@ -10887,34 +10902,39 @@ keeps the test command's own path behavior. ### Tier SYN — The public `` component -Named `SYN` rather than `SY` or `SL`, which already name the syntax catalog and -own-scope context updates. The catalog *value* is Tier SY's; this tier is the +Named `SYN` rather than `SY` or `SL`, which already name the syntax symbols and +own-scope context updates. The symbols *value* is Tier SY's; this tier is the component that observes one at an authored site. | # | Test | Verify | |---|------|--------| -| SYN1–SYN4 | One occurrence | The bare form renders the catalog once and `as` binds the same text emitting nothing; a paired spelling and an authored prop refuse before any observation; two occurrences observe independently and a reused binding observes nothing again | +| SYN1–SYN4 | One occurrence | The bare form renders the symbols once and `as` binds the same text emitting nothing; a paired spelling and an unknown prop refuse before any read; two occurrences read independently and a reused binding reads nothing again | | SYN5–SYN10 | The name canonical core owns | A repository `Syntax.md`, `Syntax.ts` and directory candidate never win selection, with an ordinary nearby component as the positive control that repository discovery is live; ordinary and reserved registrations are refused atomically; a workflow bundle member and a host's declared Markdown are each refused at admission before the root import | | SYN6 | The origin selection reports | Selection answers `{ kind: "protected", origin: "@executablemd/core" }` — its own kind, not a reserved registration | | SYN11–SYN15 | The import chain | Middleware that answers, substitutes, mutates, redirects, delegates twice or reuses another import's definition cannot run a replacement; ordinary delegation reaches canonical ``; a deliberate middleware refusal stays a refusal; document-authored context and a look-alike observation change nothing | | SYN16–SYN18 | The site described | An ordinary run reports its own includes and registry; a workflow root reports its bundle without importing or running a member; a declared Markdown component's body reports the site it inherited | -| SYN20–SYN22 | The record kept | Continuation restores the retained catalog after the environment moves and rediscovers nothing; missing, additional and wrong-typed payloads refuse before output or binding; a cancelled observation completes teardown and commits nothing | -| SYN23, SYN25b | Never authority | A catalog naming a component neither registers, resolves nor authorizes it; a fixed narrower observation answers with exactly the catalog it was handed and adds nothing — the seam `` installs through | +| SYN20–SYN22 | The record kept | Continuation restores the retained symbols after the environment moves and rediscovers nothing; missing, additional and wrong-typed payloads refuse before output or binding; a cancelled read completes teardown and commits nothing | +| SYN23, SYN25b | Never authority | Symbols naming a component neither register, resolve nor authorize it; a fixed narrower reference answers with exactly the symbols it was handed and adds nothing — the seam `` installs through | | SYN24 | One description | Inspection and validation describe the component identically, from one declaration | | SYN26 | Another loaded copy | A protected implementation built by a second loaded copy answers for nothing in the active execution | -| SYN27 | Protected provenance | The catalog reports the component under the `protected` origin kind in both the structured entry and the rendered Markdown, and never as a reserved registration; `inspectComponent` agrees | +| SYN27 | Protected provenance | The symbols report the component under the `protected` origin kind in both the structured entry and the rendered Markdown, and never as a reserved registration; `inspectComponent` agrees | | SYN28 | Pinned provenance | A workflow-bundle component is reported at its path *and* blob object id, under the `workflow` origin kind, and stays in the user-provided category | -| SYN29 | The named form | Selected entries render their metadata and documentation once each in catalog order, not request order, with nothing else of the catalog; `as` binds identical text | +| SYN29 | The named form | Selected entries render their metadata and documentation once each in symbol order, not request order, with nothing else of the symbols; `as` binds identical text | | SYN30 | Availability and absence | Each entry states whether it is available in the current evaluation; a selected entry with no authored documentation renders its metadata and says so | | SYN31 | Atomic refusal | An unknown name, an empty list, a duplicate, a non-string member, a non-array value and an undeclared prop each refuse with no successful retained result | -| SYN39 | Named retention | The occurrence retains its final rendered text, a continuation restores it without rereading documentation or rebuilding the catalog, and a corrupted record refuses | -| SYN25c | The narrowing seam | A narrowed observation reports the narrowed vocabulary bare, documents the enclosing catalog by name, and marks each entry's availability truthfully in both directions | +| SYN39 | Named retention | The occurrence retains its final rendered text, a continuation restores it without rereading documentation or rebuilding the symbols, and a corrupted record refuses | +| SYN25c | The narrowing seam | A narrowed observation reports the narrowed vocabulary bare, documents the enclosing symbols by name, and marks each entry's availability truthfully in both directions | | SYN32–SYN34 | Parsing one file | Bundle prose, a section per level-two heading with deeper headings kept inside it, a fenced heading read as the example it is, and a refusal for a duplicate section or a heading that is not a component name | | SYN35–SYN37 | Building the index | A heading naming something the package does not supply refuses; one component documented twice refuses; documentation attaches by name and owning package, never to a repository replacement | | SYN38 | Exact coverage | A package supplying a component it does not document refuses the whole index — deleting any one built-in's section fails — with a fully covered package as the positive control | | SYN40 | The protection boundary | A repository component wrapping the named form with planted `API.Fs` middleware cannot change what the documentation says, and the read never reaches that Api | | SYN41–SYN45 | The build gate | `scripts/validate-documentation.ts` assembles the complete first-party index before any distribution is produced: the shipped set passes, and a deleted section, an unknown heading, a duplicated section, and drift in a package outside core each fail it | -| SYN46 | Cancelling a named observation | Teardown completes and no successful `syntax_catalog` record is committed | +| SYN46 | Cancelling a named read | Teardown completes and no successful `syntax_symbols` record is committed | +| SYN25g | Collection captures by value | Rewriting a contribution's source object, its text, its owner and its name set after the collector returned changes neither the snapshot nor what a reference built from it renders | +| SYN25h | One call, both halves | A profile whose package bootstrap was not entered describes the component and says it is undocumented; entering the bootstrap supplies the prose *and* keeps canonical core's own, so a wrapper that replaced rather than appended fails here | +| SYN25i | Duplicates refuse either way | Two contributions naming one component of one package refuse whichever order they were bootstrapped in, and each refusal names the pair it actually saw | +| SYN25j | Scopes are isolated | A sibling scope that bootstrapped nothing reads none of the first scope's contributions, and the first scope's contribution does not outlive it | +| SYN25k | Document-time middleware reaches nothing | A component that composes around the `Documentation` Api and renders `` inside its own scope is shown what the host bootstrapped, not what it installed | | SX17 | One index, two surfaces | `xmd syntax NAME` and `` return the same text for a component outside core's own file | ### Tier SX — The `xmd syntax` command @@ -10923,13 +10943,13 @@ component that observes one at an authored site. |---|------|--------| | SX1–SX3 | The run profile | Core, Agent, testing and web defaults and `` are described from the declarations the runtime installers register, with no execution claimant minted | | SX2 | Documentation completeness | Every complete built-in in the profile states a description, and the testing contracts read as they are: `` requires `message` and binds the caught error segment, an ordinary assertion binds its diagnostic report, and an assertion that refuses expected children reports one form | -| SX4–SX6 | Renderers take a value | Both formats render from a supplied catalog with the filesystem refusing every call, twice with identical bytes, under the fixed category headings; every table cell is escaped, a prop name holding a pipe included | +| SX4–SX6 | Renderers take a value | Both formats render from a supplied symbols with the filesystem refusing every call, twice with identical bytes, under the fixed category headings; every table cell is escaped, a prop name holding a pipe included | | SX7/SX8 | Includes | Repeated values select in caller order and replace the defaults; absent, the defaults apply | -| SX9 | Failure | An unusable include exits 1, reports on stderr and prints no catalog | -| SX10/SX11 | Formats | Markdown by default, version-2 JSON with `--json`; the catalog is inspection, and `xmd plan` is the command that writes with the same structured value | -| SX16 | Named lookup | `xmd syntax Elicit` renders that component's metadata and long-form documentation through the same selection, index and renderer `` uses; the compact catalog is unchanged and an unknown name refuses whole | +| SX9 | Failure | An unusable include exits 1, reports on stderr and prints no symbols | +| SX10/SX11 | Formats | Markdown by default, version-2 JSON with `--json`; the symbols are inspection, and `xmd plan` is the command that writes with the same structured value | +| SX16 | Named lookup | `xmd syntax Elicit` renders that component's metadata and long-form documentation through the same selection, index and renderer `` uses; the compact symbols are unchanged and an unknown name refuses whole | | SX12 | A package tree | Bare `xmd syntax` succeeds with the default includes in a repository whose `node_modules` holds directory links | -| SX13–SX15 | Delivery | A real pipeline reading a catalog larger than one pipe buffer receives the bytes a regular-file redirect receives, in both forms; a consumer that closes early leaves the command reporting on stderr with exit 1 rather than an unhandled write failure | +| SX13–SX15 | Delivery | A real pipeline reading symbols larger than one pipe buffer receives the bytes a regular-file redirect receives, in both forms; a consumer that closes early leaves the command reporting on stderr with exit 1 rather than an unhandled write failure | ### Tier SDL — Delivering a rendered result @@ -10972,7 +10992,7 @@ every refusal is proven by the phase tripwires that stayed at zero. | # | Test | Verify | |---|------|--------| -| PS1–PS3 | Fixed grammar | One request preserved byte for byte and a second positional refused; every retained option accepted before and after it; every `--run` spelling answered with the migration, and every other removed option — both short aliases and the aggregate and generated property names included — answered with the one refusal that names `xmd run`, before any catalog, Agent, session, review or filesystem activity, and before `--help` can short-circuit the dispatch in either order; a name that merely begins like a property option keeps the generic unknown-option refusal; a first token of `prompt` refused in preflight rather than read as a document path, with `xmd run ./prompt` still executing a document of that name | +| PS1–PS3 | Fixed grammar | One request preserved byte for byte and a second positional refused; every retained option accepted before and after it; every `--run` spelling answered with the migration, and every other removed option — both short aliases and the aggregate and generated property names included — answered with the one refusal that names `xmd run`, before any symbols, Agent, session, review or filesystem activity, and before `--help` can short-circuit the dispatch in either order; a name that merely begins like a property option keeps the generic unknown-option refusal; a first token of `prompt` refused in preflight rather than read as a document path, with `xmd run ./prompt` still executing a document of that name | | PS4/PS5 | Help | The complete `xmd plan --help` output and the program summary carry only the retained grammar, both explicit compositions and the journal warning, and no removed option appears in either; `xmd run --help` still exposes every option it configures | | C2–C3 | The packaged adapter and Component | The command executes the checked-in Markdown value root under ``, which invokes the packaged `` Component, and the turn text is that Component's own words; generation, repair, review, revision, approval, stopping, exhaustion and the final explanation are Markdown under visible headings, every Plan-producing turn states the complete Plan requirements for itself, `` stays one turn, and what a person reads says each thing once however many rounds it took | | C4–C6 | Session and ceiling | One enclosing Session carries every turn, defaults differ per invocation and `--session` supplies the exact override; the authorship profile gives the assistant an empty host-owned directory, no MCP servers, no native tools and a private strict denial no command line reaches; a draft is data throughout, and no draft effect ever happens | @@ -10996,9 +11016,9 @@ rather than restating. | PO1–PO3 | Phases and counters | Each phase precedes the work it names, and one reaches the operator while a turn is still blocked; the repair and attempt ordinals come from the loop bounds and stop at them; Stop and exhaustion announce themselves and keep their exact endings | | PO4/PO5 | Surface and disclosure | Neither ordinary `` form announces anything or expands a progress body; default progress holds no request, draft, diagnostic, feedback or approved source, and verbose adds exactly the two blocks, in phase order | | PO6/PO7 | Channels and grammar | A non-terminal stderr receives normalized Markdown and a stated terminal receives it rendered, while stdout and `--output` stay byte-identical; `--verbose` and `--journal` work on either side of the request, help carries them and the journal warning, and the short aliases, every removed spelling and a retained option that reaches this grammar written where the journal path goes all refuse before any work, while `--help` keeps its ordinary precedence | -| PO8/PO9/PO16 | The journal file | No `--journal` writes no file; one creates the path before the catalog and the first turn, parses as the existing JSONL in commit order, ends terminally and holds no program execution; an existing path and an uncreatable one each report their exact refusal and reach nothing; and an ordinary failure — where no append failed — leaves a wholly parseable file with no partial trailing record | +| PO8/PO9/PO16 | The journal file | No `--journal` writes no file; one creates the path before the symbols and the first turn, parses as the existing JSONL in commit order, ends terminally and holds no program execution; an existing path and an uncreatable one each report their exact refusal and reach nothing; and an ordinary failure — where no append failed — leaves a wholly parseable file with no partial trailing record | | PO10–PO12 | The secret and persistence boundaries | A secret in a draft or in a failed check's findings reaches neither the progress nor the file while the earlier prefix stays readable, and the same values without it are shown and recorded; a refused entry reports the exact journal-write diagnostic and preserves what committed | -| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is observed once, through public ``, while continuation restores that observation without rebuilding it | +| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the symbols are observed once, through public ``, while continuation restores that observation without rebuilding it | ### Tier UG — The `xmd upgrade` command @@ -11285,11 +11305,11 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | WRR10/WRR10b | Outer rollback cache coherence | Failure and cancellation after an uncommitted removal and negative lookup roll back and invalidate both authoritative DOFS caches | | WRR11 | Historical file size | Every historical file entry's declared size agrees with its retained DOFS manifest during read-only recognition | -### Tier DT — Document target catalog, selectors, and projection +### Tier DT — Document target symbols, selectors, and projection | # | Test | Verify | |---|------|--------| -| DT1–DT5 | Outline | ATX and Setext headings catalog in source order; a skipped depth still nests; the outermost depth is the smallest present; a sole outermost heading is the title and several are path levels | +| DT1–DT5 | Outline | ATX and Setext headings symbols in source order; a skipped depth still nests; the outermost depth is the smallest present; a sole outermost heading is the title and several are path levels | | DT6 | Case | Matching is case-sensitive | | DT7–DT9 | Labels | Formatting, link destinations, inline code, image alt text and passive HTML tags reduce to statically rendered text; a heading rendering no text is unaddressable | | DT8 | Normalization | NFC-equivalent spellings are one label and Unicode whitespace collapses | @@ -11298,9 +11318,9 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | DT12 | Nested flow | Headings in block quotes, lists, fences, exec fences and raw HTML are not targets | | DT13 | Component children | A component child holding blank lines and `#` lines contributes no target — the regression that kills raw Remark discovery | | DT14–DT17 | Addressability | A heading overlapping component syntax or carrying an interpolation is unaddressable and blocks its subtree; escaped interpolation stays static; a computed sole title still leaves its sections addressable | -| DT18/DT19 | Empty catalog | A document with no heading addresses nothing, and a sole title is no target | +| DT18/DT19 | Empty symbols | A document with no heading addresses nothing, and a sole title is no target | | DT20–DT22 | Matching | Literal levels, embedded `*`, and `**` across zero or more levels | -| DT23 | Exactly one | Zero matches and several matches both fail, reporting matches and the catalog | +| DT23 | Exactly one | Zero matches and several matches both fail, reporting matches and the symbols | | DT24–DT26 | Selector syntax | Empty, leading/trailing slash, empty level, malformed escape, NUL and non-UTF-8 are refused; `+` is a plus | | DT27 | Termination | A wildcard-dense selector against a long label completes without exponential search | | DT28 | Wildcard whitespace | Whitespace beside a wildcard is matched; only the level's outer edges trim | @@ -11310,15 +11330,15 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | DT50/DT51 | Formatter totality | A path that cannot encode losslessly — NUL, an unpaired surrogate — is refused, and every formatted reference parses back to what it named | | DT34–DT39 | Projection | Preamble, ancestor direct content and the selected subtree are retained; siblings are absent; a non-leaf keeps its descendants; a sole title stays | | DT40–DT43 | Positions | A retained element keeps its authored offset and line, CRLF included; frontmatter, props and return mode survive; the untargeted parse still scans the whole body | -| DT44–DT47 | Inspection | The catalog is reported without selecting; a glob resolves to the exact target; an unresolvable target fails inspection; the failure's data is frozen and rebuilt | +| DT44–DT47 | Inspection | The symbols report without selecting; a glob resolves to the exact target; an unresolvable target fails inspection; the failure's data is frozen and rebuilt | | DT67–DT78 | Descriptions | The first direct static paragraph describes its section, past blank lines and comment-only HTML, kept whole with formatting reduced and whitespace collapsed; a fence, component, list, quote or child heading first leaves no description and later prose is not reached; an interpolation or an inline component omits the paragraph whole rather than as a static prefix | -| DT79/DT80 | Structured catalog | `targetInfo` carries the same targets, order and duplicates as `targets`, each with its own description, on the unselected path and beside an unchanged exact `target` on the selected one | +| DT79/DT80 | Structured symbols | `targetInfo` carries the same targets, order and duplicates as `targets`, each with its own description, on the unselected path and beside an unchanged exact `target` on the selected one | | DT52/DT53 | Recognition | A failure from a separately loaded copy, and one built here, are read on the same terms | | DT54–DT56 | Reconstruction | The result is a fresh local error, never the candidate; a mutable nested list is copied and later mutation changes nothing; a revoked Proxy cannot reach through a result already built | | DT57 | Closed data | Enumerable, non-enumerable and symbol-keyed extras are refused | | DT58 | Canonical lists | Raw spaces, tabs, no-break spaces, edge whitespace, lowercase escapes, NUL, non-string entries and sparse lists are refused — asserted against the data parser, so the derived message cannot mask the check | | DT59 | Dotted heading paths | `.` and `..` are legal heading labels, so `../../etc/passwd` and `Alpha/../Beta` are canonical heading paths, never filesystem authority; structural parsing accepts them when the rest of the failure is consistent | -| DT60 | Semantic outcome | Fields no selection could have produced — a `no-match` whose selector matches, a single-match ambiguity, a match outside the catalog, an `invalid-selector` that parses — are refused | +| DT60 | Semantic outcome | Fields no selection could have produced — a `no-match` whose selector matches, a single-match ambiguity, a match outside the symbols, an `invalid-selector` that parses — are refused | | DT61/DT62 | Closed shell | A cause, an enumerable payload, and a message that does not derive from its data are refused; no planted payload survives stringification, spreading, symbol enumeration, or a journal round trip | ### Tier TX — Targeted execution and replay @@ -11337,7 +11357,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | TX22/TX23 | Recorded content | An untargeted journal replays untargeted; replay projects the recorded content, not the file on disk | | TX24 | Failed selection | A journal from a selector that matched nothing never answers a later valid one | | TX25–TX27 | Failed replay | The same failing selector replays its own recorded failure with no authored effect; a different failure kind or a different selector is stale; live and replayed failures are the same structural error | -| TX28–TX32 | Malformed records | Starting from a valid failed-selection journal and corrupting only the record: a missing or non-array catalog, an unknown kind, extra record or failure data, a catalog or target the recorded document does not derive, and inconsistent kind/matches data are each refused before completed-Close reuse, resumed with the failing selector and with a valid one, expanding nothing and appending nothing | +| TX28–TX32 | Malformed records | Starting from a valid failed-selection journal and corrupting only the record: a missing or non-array symbols, an unknown kind, extra record or failure data, symbols or target the recorded document does not derive, and inconsistent kind/matches data are each refused before completed-Close reuse, resumed with the failing selector and with a valid one, expanding nothing and appending nothing | | TX33 | Not vacuous | An uncorrupted record still replays its recorded failure | | TX34–TX37 | Totality, inside the value | Recorded markdown whose frontmatter no parser accepts, an unreadable member, a record refusing key enumeration, and a value that is not a record at all each become the one fixed diagnostic — cause-free, carrying no planted value, expanding nothing and appending nothing | | DT63–DT66 | Preamble boundary | A section before the title is addressable and is not preamble; selecting a later section neither renders nor executes it; it stays independently addressable; real preamble text before the first heading is still retained | @@ -11965,8 +11985,8 @@ Identifiers match `packages/core/tests/switch.test.ts` one to one. | SW58 | Partial replay | From a journal prefix without the root Close, the selected effect restores, selection is rebuilt, the run continues at the next live effect and the output is reproduced | | SW59 | Completed replay | The retained result is returned with no selector, matcher, import or block | -The shared entrypoints carry the rest: `syntax-catalog.test.ts` SY4b and SY5b -freeze both catalog entries and prove no repository file or registration can +The shared entrypoints carry the rest: `syntax-symbols.test.ts` SY4b and SY5b +freeze both symbol entries and prove no repository file or registration can supply either name; `document-validation.test.ts` Tier DV `` covers static and dynamic operands, a definite failure beside a dynamic one, the diagnostic a case shares with its switch, nested ownership, a stray case and the @@ -12307,7 +12327,7 @@ timed. The behavioral rows run the real `xmd run` command against a document on disk, so what they observe is what a reader of that command sees. VB5 is a TypeScript -row: it inspects the catalog without running anything. +row: it inspects the symbols without running anything. | Criterion | Evidence | | --- | --- | @@ -12350,8 +12370,8 @@ user's own `~/.xmd/repositories`. | # | Test | Verify | |---|------|--------| -| ORC1 | One declaration surface | All thirteen names appear in the catalog with complete contracts; a repository file of one of those names shadows the default; catalog construction performs no ambient discovery, lock, Git, credential or network operation | -| ORC2 | Runtime declaration parity | The same catalog assertion holds under Deno, Node and Bun; on a runtime that installs no operational repository provider, representative Repository, Worktree, Git, Issue and PullRequest forms each report an absent provider with zero mutation, while `` remains operational through that runtime's host `API.Files` provider | +| ORC1 | One declaration surface | All thirteen names appear in the symbols with complete contracts; a repository file of one of those names shadows the default; symbol construction performs no ambient discovery, lock, Git, credential or network operation | +| ORC2 | Runtime declaration parity | The same symbols assertion holds under Deno, Node and Bun; on a runtime that installs no operational repository provider, representative Repository, Worktree, Git, Issue and PullRequest forms each report an absent provider with zero mutation, while `` remains operational through that runtime's host `API.Files` provider | | ORC3 | Ambient primary checkout | From a normal repository, root Switch/Add/Commit select the ambient Repository and the contextual checkout; outside Git, a root Worktree, Git operation or PullRequest refusal names how to run inside one | | ORC4 | Ambient linked worktree | Invoked from a linked worktree, Repository identity follows the canonical common directory, Git acts on that worktree's root, and the primary checkout is untouched | | ORC5 | Origin is not local authority | A repository with no `origin` creates a Worktree and performs local Git; Push and PullRequest refuse before a credential, session or transport exists | diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 3df36f5b..6f9b00c9 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -87,7 +87,7 @@ fixed command preflight -> --journal: exclusively create the named path -> execute the exact packaged plan command document, which is an adapter -> , the packaged Component, with the request as its Prompt - -> announce Preparing, then build the run-profile syntax catalog + -> announce Preparing, then build the run-profile syntax symbols -> the authorship frame, and one Session inside it -> generate, check, repair, review, revise, approve, explain or fail, announcing each phase on stderr before it happens @@ -100,7 +100,7 @@ fixed command preflight ``` Each phase hands the next one a value. No phase after the first failure begins, -so a refused command line reaches no catalog, a failed turn reaches no review, +so a refused command line reaches no symbols, a failed turn reaches no review, and a review that stopped reaches no stdout and no file. Writing a Plan is a conversation, and a conversation is not a run. The durable @@ -143,7 +143,7 @@ result. | Option | What it configures | | --- | --- | -| `--include …` | the ordered component search path the syntax catalog and the structural checks resolve through | +| `--include …` | the ordered component search path the syntax symbols and the structural checks resolve through | | `--agent-provider ` | which provider writes the Plan | | `--default-agent ` | which agent that provider defaults to, overriding `DEFAULT_AGENT_NAME` | | `--session ` | the logical assistant session the planning conversation belongs to | @@ -258,12 +258,12 @@ and the refusal says where an approved Plan goes now. `-e`/`--eval` stays exclusive to `xmd run`. A plan supplies a request, not a document. Supplying one anyway is refused in the command's own preflight, with `unrecognized option for xmd plan: --eval — inline documents are exclusive to -xmd run`, before the catalog, the command document, the review or the file +xmd run`, before the symbols, the command document, the review or the file exists. `--agent-provider` and `--default-agent` are resolved into one authorship configuration once per invocation, and an unknown provider fails there, before -the catalog is built. No permission mode is settled: this command starts no +the symbols are built. No permission mode is settled: this command starts no program, and the ceiling its authorship runs under is the host's rather than the command line's. @@ -288,7 +288,7 @@ The message answers both readings, because the token is ambiguous by construction. Nothing else changes: only the exact first token is recognized, so `xmd run ./prompt`, `xmd run prompt` and `xmd ./prompt` still execute a document that is legitimately called that. The refusal exits nonzero and establishes -nothing — no catalog, no Agent, no Session, no authorship directory and no +nothing — no symbols, no Agent, no Session, no authorship directory and no output. ### `--session ` @@ -300,7 +300,7 @@ never falls back to the generated one by accident. Ordinary provider session continuation applies when the configured provider already holds that name. The plan command document still supplies the current -request and the current catalog in this invocation's initial turn. A continued +request and the current symbols in this invocation's initial turn. A continued conversation still produces source and starts no program. ### Help @@ -310,7 +310,7 @@ xmd plan --help ``` Help needs no request. It describes the request, `--output`, `--session`, -`--verbose`, `--journal`, the authorship and catalog options and the deadline; +`--verbose`, `--journal`, the authorship and symbol options and the deadline; it states that the approved Plan is the only result, that stdout carries its exact bytes when `--output` is absent, and that planning never runs the approved program — and it writes out both explicit compositions. No removed option @@ -322,7 +322,7 @@ It ends with what a journal costs, separated from everything above it: Secret detection checks journal entries before they are recorded, but it may not catch every sensitive detail. The journal can contain prompts, drafts, and review answers. ``` -Help reads no catalog, contacts no provider, places no session, asks nobody +Help reads no symbols, contacts no provider, places no session, asks nobody anything, creates no file and runs nothing. ## The packaged plan command document @@ -341,17 +341,17 @@ The host supplies two fixed internal inputs as that root's props: - `session` — the resolved logical assistant-session name. They are the adapter's own, and nothing a Plan declares is bound here: the -properties a Plan's root declares are resolved by whoever runs it. The catalog -is not among them either. The command states the vocabulary its profile +properties a Plan's root declares are resolved by whoever runs it. The symbols +are not among them either. The command states the vocabulary its profile describes at the execution boundary, captured before any installed code runs, -and the packaged Component reaches it by writing the public `` any +and the packaged Component reaches them by writing the public `` any document may write — so an authored phase can say that preparation is starting -before the observation happens, and the catalog the Agent is shown is the one an +before the read happens, and the symbols the Agent is shown are the ones an operator can print. The profile it states is the ordinary `run` one, in the caller's includes. A Plan is a program a later `xmd run` executes, and this authorship execution -searches no repository and refuses almost every capability, so a catalog derived +searches no repository and refuses almost every capability, so symbols derived from it would describe a vocabulary the approved program would not have. **The root is an adapter, not the workflow.** Its whole body is two elements: it @@ -379,7 +379,7 @@ placements stay distinct even when their authored names match. The host owns the provider instruction layer and the Agent ceiling. The Markdown owns the text of each generation, repair and revision request: the initial prompt -preserves the Prompt, includes the host's catalog, and asks for one complete +preserves the Prompt, includes the host's symbols, and asks for one complete replacement root as source only — written as a Plan, with every requested outcome kept as reader-facing prose and each component placed immediately after the sentences describing the action it performs. That authorship rule is repeated in @@ -409,15 +409,15 @@ bytes after that teardown and retains them as one Plan artifact — the invocati identity, the instruction identity, the approved source, its digest and that successful admission — before the Component renders them. -**The catalog is not one of them.** What a document may write is a public +**The symbols are not one of them.** What a document may write is a public question with a public answer, and canonical core owns both, so `Plan.md` writes the same `` any document writes and binds the vocabulary directly into every authorship prompt. Its retention is core's: one -`syntax_catalog` observation per occurrence, retaining exactly -`{ catalog: string }`, hostile-parsed on continuation so a resumed authorship is +`syntax_symbols` read per occurrence, retaining exactly +`{ symbols: string }`, hostile-parsed on continuation so a resumed authorship is shown the vocabulary the run actually showed it rather than one rebuilt from a tree that has moved. `` retains exactly `{ instruction }` beside it, -so the catalog and the question are two records that can be read and reconciled +so the symbols and the question are two records that can be read and reconciled independently, and a missing, additional or mistyped member in either refuses before authorship begins. @@ -591,8 +591,8 @@ the complete versioned `DocumentValidation` core produced. The component performs no candidate execution. It asks the invocation's one structural check — `validateDocumentStructure()` under the ordinary run-profile registry, the `` identity, the caller's ordered includes and the run -profile's declarations, which include `` itself because the catalog the -agent was shown says the profile has it. The admission that follows teardown and +profile's declarations, which include `` itself because the symbols the +agent was shown say the profile has it. The admission that follows teardown and the command's own gate ask that same check, so the three cannot come to differ about what a program is for a reason nobody chose. @@ -661,7 +661,7 @@ budget. **The explanation turn.** A tenth draft that still has problems after its repairs leaves nothing to approve and nothing left to revise into, so no review opens for it: there is no decision to offer. The workflow instead makes exactly -one more `` in the same enclosing Session, automatically. The Session already holds the original Prompt, the catalog, +one more `` in the same enclosing Session, automatically. The Session already holds the original Prompt, the symbols, every draft, every earlier diagnostic and every revision request, so nothing is resent: the turn carries only the final diagnostics, which were produced after the agent's last draft and have not appeared in the conversation. It asks for a @@ -712,7 +712,7 @@ is happening rather than an account of what already finished. The phases are: | Phase | Announced before | | --- | --- | -| Preparing the Plan | the syntax catalog is built and the session is set up | +| Preparing the Plan | the syntax symbols are built and the session is set up | | Drafting the Plan | the first Agent turn, naming which of the ten attempts this is | | Checking the draft | every structural check, including the ones after a repair | | Repairing the draft | each repair turn, naming which of the three repairs this is | @@ -850,7 +850,7 @@ Help says plainly that the gate may not catch every sensitive detail and that the journal can contain prompts, drafts and review answers. -An existing path is refused before catalog preparation, session placement, Agent +An existing path is refused before symbol preparation, session placement, Agent startup, review or artifact creation, and is left byte-identical: ```text @@ -927,7 +927,7 @@ writes. ## Timeouts -`--timeout` bounds the whole command: preflight, catalog construction, the +`--timeout` bounds the whole command: preflight, symbol construction, the command document's execution, Elicitation, its teardown, the structural validation and the artifact. It covers no later program, because this command starts none. Expiry is Effection cancellation, so structured teardown completes @@ -946,9 +946,9 @@ ending of this command does. | Failure | Reaches | | --- | --- | | a malformed command line, a removed option, an unknown option, or `--save` | nothing | -| a `--journal` path that exists, or one this command cannot create | no catalog, session, turn, review, stdout or file | +| a `--journal` path that exists, or one this command cannot create | no symbols, session, turn, review, stdout or file | | an unknown `--agent-provider` | nothing | -| a catalog an include makes unreadable | no turn, review, stdout or file | +| symbols an include makes unreadable | no turn, review, stdout or file | | a `--journal` entry the file will not take | no stdout or file; the committed prefix stays | | a progress destination that stops accepting bytes | no stdout or file; accepted bytes stay | | a host that supplies no Agent context, or a provider that cannot establish the authorship profile's ceiling | no session, no turn | @@ -981,7 +981,7 @@ neither observation never interpreted what it wrote. | # | Criterion | Required observation | | --- | --- | --- | | PS1 | Fixed grammar | Every retained option is accepted before and after the request, one request is preserved byte for byte, and a second positional is refused with the approved sentence | -| PS2 | The removed switch | Bare, valued, repeated, before-request, after-request and value-position `--run` forms all return the exact migration text, before any catalog, Agent, session, review, filesystem or document activity | +| PS2 | The removed switch | Bare, valued, repeated, before-request, after-request and value-position `--run` forms all return the exact migration text, before any symbols, Agent, session, review, filesystem or document activity | | PS3 | The removed options | One representative of every other removed class, both short aliases, and the aggregate and generated property names return the exact generic refusal before authorship, ahead of the shared timeout and secret-detection grammar checks; a name that merely begins like a property option keeps the generic unknown-option refusal | | PS4 | Help | The complete `xmd plan --help` output and the program summary contain only the retained grammar and both explicit compositions; no removed option appears anywhere in either. Help beside every retained option is still help; help beside a removed one, in either order, is that option's refusal | | PS5 | Run is unchanged | `xmd run --help` still exposes its execution, prop, permission, timeout, presentation, journal and secret-detection options | @@ -993,19 +993,19 @@ neither observation never interpreted what it wrote. | PS11 | Adapter and Component | The command document remains the exact thin adapter, and `` remains a bare-or-captured exact text component | | PS12 | Product copy | Architecture, specifications, README and the homepage state that Plan produces source, Run executes source, and composition decides when it runs | | C2–C5, C8, C9, C13, C14 | Authorship | The packaged adapter and Component, one Session, the profile ceiling, the repair and review bounds, safe presentation, the authored endings, directory lifetime and narrative preservation are unchanged by this command producing source only, and keep their evidence | -| PO1 | Progress precedes the work | Preparing arrives before the catalog is built, Drafting before the first turn, Checking before validation, Waiting before review and Finalizing before authorship teardown; an early phase reaches the operator while a turn is still blocked | +| PO1 | Progress precedes the work | Preparing arrives before the symbols are built, Drafting before the first turn, Checking before validation, Waiting before review and Finalizing before authorship teardown; an early phase reaches the operator while a turn is still blocked | | PO2 | Counters come from the bounds | One invalid attempt uses repair ordinals 1st–3rd with a check before each result; a requested change announces the 2nd attempt; the counters reach the 10th and there is no 11th | | PO3 | Terminal phases | Stop announces itself before teardown and keeps its exact final diagnostic; a tenth-attempt exhaustion announces itself before the automatic explanation, opens no review and produces no Plan | | PO4 | The ordinary surface is silent | A bare `` emits only exact approved source, a captured `` binds the same bytes and emits nothing, and neither expands a progress body whatever verbosity the declaration carries | | PO5 | Disclosure | Default progress excludes the request, the drafts, the diagnostics, the feedback and every Agent, provider and tool output; verbose adds every cleared draft and each invalid check's exact structured JSON, in phase order, and nothing else | | PO6 | Channels | A non-terminal stderr receives normalized Markdown, a stated terminal receives it rendered, and stdout and `--output` stay byte-identical exact source in both | -| PO7 | The two options | `--verbose` and `--journal` are accepted on either side of the request, help contains them and the journal warning, and `-V`, `-j`, `--trace` and every removed spelling refuse before the catalog or a session exists; a retained option that reaches this command's grammar, written where the journal path goes, is that option rather than a filename and refuses before any catalog, session, provider, filesystem or artifact work — while `--help` and `-h` keep their ordinary precedence and answer with help, creating no journal and beginning no authorship | -| PO8 | The journal file | With no `--journal` no file appears; with one, the path exists before the catalog and the first turn, a successful trace parses as the existing JSONL events in commit order and ends terminally, and it holds no program-execution event | -| PO9 | Journal refusals | A pre-existing journal is byte-identical and refuses with the exact copy before any catalog, session, turn, review or artifact work; a path that cannot be created reports the other exact copy | +| PO7 | The two options | `--verbose` and `--journal` are accepted on either side of the request, help contains them and the journal warning, and `-V`, `-j`, `--trace` and every removed spelling refuse before the symbols or a session exists; a retained option that reaches this command's grammar, written where the journal path goes, is that option rather than a filename and refuses before any symbols, session, provider, filesystem or artifact work — while `--help` and `-h` keep their ordinary precedence and answer with help, creating no journal and beginning no authorship | +| PO8 | The journal file | With no `--journal` no file appears; with one, the path exists before the symbols and the first turn, a successful trace parses as the existing JSONL events in commit order and ends terminally, and it holds no program-execution event | +| PO9 | Journal refusals | A pre-existing journal is byte-identical and refuses with the exact copy before any symbols, session, turn, review or artifact work; a path that cannot be created reports the other exact copy | | PO10 | A secret in a draft | It reaches neither the progress nor the journal, the earlier prefix stays readable, teardown completes, and no source or artifact is delivered — while the same draft without it is displayed and recorded | | PO11 | A secret in a diagnostic | The same, for a failed check's structured findings | | PO12 | A refused entry | An append failure after a committed entry reports the exact journal-write diagnostic, preserves the records committed before it, completes teardown and delivers no Plan | | PO16 | An ordinary failure | A journal-backed invocation that fails for its own reason — a failed turn, with neither a secret rejection nor a write failure — exits non-zero, delivers no source and no artifact, completes teardown, and leaves a file whose every entry parses and whose bytes are exactly those entries re-serialized: no append failed, so there is no partial or unterminated trailing record | | PO13 | A failed destination | A consumer that fails while a turn is live cancels that turn, waits for every owned teardown, attempts no artifact sink, keeps the bytes stderr accepted, and uses the exact progress-failure diagnostic | | PO14 | Ordering is unchanged | Cancellation, teardown failure, final validation refusal, the `--output` refusal and a successful delivery all keep their order, and no phase claims an artifact was delivered | -| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is observed exactly once, through public ``, after Preparing; continuation restores that observation without rebuilding it | +| PO15 | The adapter and the symbols | The packaged adapter emits no prose of its own, and the symbols are observed exactly once, through public ``, after Preparing; continuation restores that observation without rebuilding it | From 07ef1da819ad2a94a6b9484898aa062268834d67 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 02:28:15 -0400 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=9A=91=20Carry=20the=20settled=20Sy?= =?UTF-8?q?ntax=20terminology=20into=20the=20distribution=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suites assert against a built artifact rather than the source tree, so none of them typechecks against `Syntax.ts` and none was in the local matrix. All three still held the previous wording or the removed installation field. The staged JSR consumer now bootstraps with `useAgentComponents()` instead of handing `executeInstalled` a `documentation` field that no longer exists. That field was silently ignored — the generated consumer is a string, so nothing typechecked it — and the consumer rendered `` as undocumented. The replacement is one call, which is the point of the bootstrap being one thing. The npm-binary and compiled-binary suites assert the approved description, which changed with the rename. --- scripts/tests/cli-npm-bin.test.ts | 4 ++-- .../tests/jsr-consumer-documentation.test.ts | 17 ++++++++--------- scripts/tests/plan-component-compiled.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 1e554684..fc9bad30 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -240,8 +240,8 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( - "Inspect components and control-flow constructs. `` renders the current " + - 'catalog; `` renders selected documentation.', + "Inspect available components and control-flow constructs. `` lists the " + + 'symbols available here; `` renders selected documentation.', ); // The documentation assets travel with the package, and the emitted binary diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index a4b9f414..a1062879 100644 --- a/scripts/tests/jsr-consumer-documentation.test.ts +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -131,13 +131,10 @@ describe("Tier SYN — a staged JSR consumer", () => { [ "// The public surface, assembled the way a consumer would: core's own", "// registrations plus its Agent boundary, so the document can name a", - "// component from each of the two documentation assets.", - "import {", - " AGENT_REGISTRATIONS,", - " agentDocumentation,", - " collect,", - " registerComponents,", - '} from "@executablemd/core";', + "// component from each of the two documentation assets. One call brings", + "// the Agent registrations and the documentation that describes them,", + "// which is the whole point of the bootstrap being one thing.", + 'import { collect, useAgentComponents } from "@executablemd/core";', "// The host boundary is its own entrypoint, and a consumer reaches it", "// the same way: `@executablemd/core/host`.", 'import { executeInstalled } from "@executablemd/core/host";', @@ -148,7 +145,7 @@ describe("Tier SYN — a staged JSR consumer", () => { "await main(function* () {", ' const content = yield* until(readFile("document.md", "utf8"));', " const rendered = yield* scoped(function* () {", - " yield* registerComponents(AGENT_REGISTRATIONS);", + " yield* useAgentComponents();", " return yield* collect(", " yield* executeInstalled(", " {", @@ -157,7 +154,9 @@ describe("Tier SYN — a staged JSR consumer", () => { " stream: new InMemoryStream(),", " includes: [],", " },", - " [{ documentation: [yield* agentDocumentation()] }],", + " // Nothing to install: the bootstrap above already put both the", + " // registrations and their documentation in this scope.", + " [],", " ),", " );", " });", diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 5f36d379..8942db74 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -112,8 +112,8 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => expect(syntax[0].forms).toEqual(["self-closing"]); expect(syntax[0].returnMode).toBe("text"); expect(syntax[0].description).toBe( - "Inspect components and control-flow constructs. `` renders the current " + - 'catalog; `` renders selected documentation.', + "Inspect available components and control-flow constructs. `` lists the " + + 'symbols available here; `` renders selected documentation.', ); // The documentation asset travels with the binary, not with a checkout. A