From b32959484a4b2ff21dac92489c84a25bb8c01115 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 03:03:44 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9A=91=20Refuse=20a=20repeated=20boot?= =?UTF-8?q?strap,=20and=20finish=20the=20Syntax=20vocabulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collection refused two contributions for one component of one package only when they named different assets. An exact repetition — what a profile that called one bootstrap twice produces — passed. Whether the second call also installed a provider, a launcher or an execution policy is not a question this boundary can answer, and the assembly is wrong either way, so it refuses now. It has to refuse at collection rather than in the named form's index. A document that writes bare `` builds no index, and one that writes no `` at all builds no reference either, so deferring would let both run to completion on an assembly nobody validated. SYN25l proves all three document shapes refuse, with a single bootstrap as the positive control. architecture.md said the assembled contributions travel by value on the execution installation, which stopped being true when the installation field was removed. It now states what is implemented: bootstraps install additive namespaced middleware, core is the terminal, and execution collects once after trusted bootstrap and snapshots by value before the root import. The rest is vocabulary the rename left behind — the `xmd syntax` broken-pipe diagnostic an operator actually reads, the `--json` help text, comments in `plan-component.ts` and `protected.ts`, the spec's "lexical observation", and the test helpers and authored bindings in the Syntax suite. Uses of "observation" that name the error-observation boundary, the document-target catalog and the diagnostic catalog are other product concepts and are left alone. --- architecture.md | 29 ++- packages/cli/src/cli.ts | 12 +- packages/cli/src/plan-component.ts | 12 +- packages/cli/tests/syntax-cli.test.ts | 2 +- packages/core/src/components/protected.ts | 2 +- packages/core/src/documentation-api.ts | 22 ++- packages/core/tests/syntax-component.test.ts | 193 +++++++++++-------- specs/executable-mdx-spec.md | 4 +- 8 files changed, 176 insertions(+), 100 deletions(-) diff --git a/architecture.md b/architecture.md index d8a775e2..8e63434b 100644 --- a/architecture.md +++ b/architecture.md @@ -3706,9 +3706,30 @@ 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 +component demands documentation rather than relying on a hand-kept list. + +**A package's bootstrap installs its registrations and its documentation +together**, through one stable namespaced `Documentation` middleware, so a host +that bootstraps a package gets both from one call. Canonical core is the +terminal, and every wrapper delegates before appending its own — which is why +composition order decides how the list reads and nothing else. 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 a +document or component that composes around the Api afterwards composes into a +chain nothing reads, two executions assembled in sibling scopes each read their +own, and a second contribution for one component of one package refuses at +collection — including an exact repetition, because a package bootstrapped twice +is a wrong assembly whichever asset the second call named. Collection is the +boundary that catches it because it is the only one every execution passes +through: a document that writes bare ``, or none at all, builds no +index and would otherwise run to completion on an assembly nobody validated. + +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. + +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 @@ -3989,7 +4010,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-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 | +| `` | 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 a second contribution for one component of one package refuses at collection — whichever order they were bootstrapped in, and including an exact repetition. 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 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) | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d75bd715..117a64c2 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -371,7 +371,7 @@ const syntaxConfig = object({ ...field(z.array(z.string()), field.default(["components", "."]), field.array()), }, json: { - description: "write the catalog as version-2 JSON instead of markdown", + description: "write the symbols as version-2 JSON instead of markdown", ...field(z.boolean(), field.default(false)), }, }); @@ -2637,7 +2637,7 @@ function* dispatch( case "syntax": { // One inspection per invocation, then one complete document. A failure // writes nothing to stdout: a healthy subset printed as though it were - // the catalog would read as complete. + // the whole set of symbols would read as complete. let rendered: string; try { const named = command.config.component; @@ -2661,13 +2661,13 @@ function* dispatch( yield* exit(1); break; } - // Only the catalog goes through delivery today, because it is the one - // output this command writes in a single call and the only one already + // Only this command's rendering goes through delivery today, because it + // is the one output written in a single call and the only one already // past a pipe buffer. const written = yield* deliverWhole(rendered, process.stdout); if (!written.ok) { console.error( - `xmd syntax: stdout did not accept the whole catalog: ${describeError(written.error)}`, + `xmd syntax: stdout did not accept the whole output: ${describeError(written.error)}`, ); yield* exit(1); } @@ -2850,7 +2850,7 @@ export function* runXmd( const provisional = xmd.parse({ args: helpRequest.args }); const selected = provisional.ok ? provisional.value.config : undefined; // The two commands a `--timeout` bounds. `xmd plan`'s deadline encloses - // something different from a run's — the catalog, the assistant session, + // something different from a run's — the symbols, the assistant session, // every repair, the human review, provider teardown, final validation and the // artifact — and covers no later program, because it starts none. const planning = selected !== undefined && !selected.help && selected.name === "plan"; diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 49ae42f4..cd2aa44f 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -39,7 +39,7 @@ * * `` 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 + * writes the same component any other document writes, and the symbols the Agent * is shown is the one an operator can print. */ @@ -121,8 +121,8 @@ export type StructuralValidation = (candidate: string) => Operation` declaration has to be able to describe itself: the - * profile a Plan will run in contains ``, and the catalog the agent was - * shown says so. + * profile a Plan will run in contains ``, and the symbols the agent was + * shown say so. */ export function structuralValidation( includes: readonly string[], @@ -327,7 +327,7 @@ export function* planComponentDeclaration( ): Operation { const source = yield* readPackagedDocument(PLAN_DOCUMENT); // The admission validates against the profile a Plan will run in, and that - // profile now contains `` — the catalog the agent was shown says so. So + // profile now contains `` — the symbols the agent was shown say so. So // the declaration has to be able to describe itself, which is why it is // assigned back rather than rebuilt: a second copy of these bytes would be a // second Component identity. @@ -368,7 +368,7 @@ export function* planComponentDeclaration( * Inspection and validation answer about what a document may write. They mint no * execution, so there is no claimant to build a private capability from and no * frame to install — and none of that is describable anyway: a private name - * is not syntax a document may write, so a catalog listing one would describe an + * is not syntax a document may write, so symbols listing one would describe an * environment that does not exist. * * What they do report is the identity: the same name, the same origin, the same @@ -437,7 +437,7 @@ function* uninvocable(): Operation { * * 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 + * binds it directly — so the symbols and the question are two records that can * be read and reconciled independently rather than one that has to be read * whole. * diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 68addfa5..a355b5f6 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -626,7 +626,7 @@ describe( expect(stdout.length).toBe(100); expect(stderr).toContain("xmd-exit=1"); - expect(stderr).toContain("stdout did not accept the whole catalog"); + expect(stderr).toContain("stdout did not accept the whole output"); // The broken pipe is reported, not raised: an unhandled write failure // ends the process with one of these instead. expect(stderr).not.toContain("Unhandled 'error' event"); diff --git a/packages/core/src/components/protected.ts b/packages/core/src/components/protected.ts index 33031f26..8c909498 100644 --- a/packages/core/src/components/protected.ts +++ b/packages/core/src/components/protected.ts @@ -24,7 +24,7 @@ * 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 + * 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. * diff --git a/packages/core/src/documentation-api.ts b/packages/core/src/documentation-api.ts index 8be9cbe8..d7bfb38f 100644 --- a/packages/core/src/documentation-api.ts +++ b/packages/core/src/documentation-api.ts @@ -123,6 +123,16 @@ export function* contributeDocumentation( * 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. + * + * **Including an exact repetition.** Two bootstraps of one package name the + * same component from the same asset, and admitting that would say a profile + * which bootstrapped a package twice is a valid profile. It is not: whether the + * second call also installed a provider, a launcher or an execution policy is + * not a question this boundary can answer, and the assembly is wrong either + * way. Collection is where that is caught, because it is the only boundary + * every execution passes through — deferring it to the named form's index + * would let a document that writes bare ``, or no `` at all, + * run to completion on an assembly nobody validated. */ export function* capturedDocumentation( read: DocumentationReader = packagedAssetReader, @@ -134,11 +144,15 @@ export function* capturedDocumentation( const owner = one.source.owner; const key = `${owner} ${name}`; const first = seen.get(key); - if (first !== undefined && first !== one.source.asset) { + if (first !== undefined) { 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.", + first === one.source.asset + ? `${owner} contributes documentation for ${name} twice, both times from ` + + `${one.source.asset}. A package is bootstrapped once per execution: a second ` + + "contribution means the assembly installed it twice." + : `${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); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 602481f2..3662a158 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -58,7 +58,7 @@ import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts"; import type { ImportedDefinition } from "../src/components/import-authority.ts"; import type { ComponentOrigin, FunctionComponent, SyntaxSymbols } from "../mod.ts"; -/** An origin a catalog *component* entry can carry — everything but structural. */ +/** An origin a *component* symbol entry can carry — everything but structural. */ type NamedOrigin = Exclude; const ROOT_PATH = "documents/root.md"; @@ -68,8 +68,8 @@ const DESCRIPTION = "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[]): SyntaxSymbols { +/** Symbols with one built-in entry per name, for a case that needs a marker. */ +function symbolsOf(...names: readonly string[]): SyntaxSymbols { return { version: 2, categories: [ @@ -94,13 +94,13 @@ function catalogOf(...names: readonly string[]): SyntaxSymbols { }; } -/** A host that states the catalog its profile describes, and counts the asks. */ -function stating(catalog: SyntaxSymbols, calls: { count: number } = { count: 0 }) { +/** A host that states the symbols its profile describes, and counts the asks. */ +function stating(symbols: SyntaxSymbols, calls: { count: number } = { count: 0 }) { const installation: ExecutionInstallation = { // deno-lint-ignore require-yield *symbols(): Operation { calls.count += 1; - return catalog; + return symbols; }, }; return { installation, calls }; @@ -153,22 +153,22 @@ function* refusal(operation: Operation): Operation { throw new Error("expected the operation to be refused"); } -/** Every retained catalog reference, in order. */ -function observations(events: readonly DurableEvent[]): DurableEvent[] { +/** Every retained syntax record, in order. */ +function syntaxReads(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( (event) => event.type === "yield" && event.description.type === "syntax_symbols", ); } /** - * Only the observations that succeeded. + * Only the syntaxReads 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. + * thing a continuation would restore and hand back as the symbols. */ function retained(events: readonly DurableEvent[]): DurableEvent[] { - return observations(events).filter( + return syntaxReads(events).filter( (event) => event.type === "yield" && event.result.status === "ok", ); } @@ -211,8 +211,8 @@ function* tampered( return partial; } -/** A catalog holding one component entry of exactly this identity. */ -function catalogNamed(name: string, origin: NamedOrigin): SyntaxSymbols { +/** Symbols holding one component entry of exactly this identity. */ +function symbolsNamed(name: string, origin: NamedOrigin): SyntaxSymbols { return { version: 2, categories: [ @@ -276,14 +276,14 @@ function useWorkingDirectory(body: (dir: string) => Operation): Operation< } 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")); + it("SYN1: the bare form renders the symbols once, and `as` binds the same text", function* () { + const { installation } = stating(symbolsOf("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]); + const captured = yield* run('\nbound:{symbols}\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:"); @@ -295,47 +295,47 @@ describe("Tier SYN — what one occurrence answers", () => { }); 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]); + const symbols = symbolsOf("Marker", "Other"); + const { installation } = stating(symbols); + const bare = yield* run('{symbols}', [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)); + expect(String(bare)).toBe(renderSyntaxMarkdown(symbols)); }); it("SYN3: a paired spelling and an authored prop refuse before any reference", function* () { - const paired = stating(catalogOf("Marker")); + const paired = stating(symbolsOf("Marker")); expect(yield* refusal(run("content\n", [paired.installation]))).toContain( "written self-closing", ); expect(paired.calls.count).toBe(0); - const propped = stating(catalogOf("Marker")); + const propped = stating(symbolsOf("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")); + // The positive control for the same host: the accepted spelling reads. + const accepted = stating(symbolsOf("Marker")); expect(String(yield* run("\n", [accepted.installation]))).toContain("Marker"); expect(accepted.calls.count).toBe(1); }); - 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]); + it("SYN4: one occurrence reads once, two read independently, a binding reads neither again", function* () { + const one = stating(symbolsOf("Marker")); + yield* run('{symbols}{symbols}{symbols}', [one.installation]); expect(one.calls.count).toBe(1); - const two = stating(catalogOf("Marker")); + const two = stating(symbolsOf("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); + yield* run("\n\n", [stating(symbolsOf("Marker")).installation], stream); + expect(syntaxReads(yield* stream.readAll()).length).toBe(2); }); }); @@ -348,10 +348,10 @@ describe("Tier SYN — the named form", () => { 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 + // Symbol 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. + // Nothing but the selection: the rest of the symbols are not here. expect(named).not.toContain("### ``"); // `as` binds the same text and emits nothing of it. @@ -484,7 +484,7 @@ describe("Tier SYN — the named form", () => { // 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(syntaxReads(events)).toHaveLength(0); expect(retained(events)).toHaveLength(0); }); @@ -530,8 +530,8 @@ describe("Tier SYN — the named form", () => { // 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"), + symbolsOf("Marker"), + symbolsOf("Marker"), captured, ).documentation(["Marker"]); expect(rendered).toContain("THE CAPTURED PROSE."); @@ -543,7 +543,7 @@ describe("Tier SYN — the named form", () => { // 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 { installation: marker } = stating(symbolsOf("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("### ``"); @@ -559,12 +559,12 @@ describe("Tier SYN — the named form", () => { // ``: 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", { + const elicit = symbolsNamed("Elicit", { kind: "registered", origin: "@executablemd/core", reserved: false, }); - const marked = catalogOf("Marker"); + const marked = symbolsOf("Marker"); const pair: SyntaxSymbols = { version: 2, categories: [ @@ -595,7 +595,7 @@ describe("Tier SYN — the named form", () => { // 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 { installation: marker } = stating(symbolsOf("Marker")); const planted: ExecutionInstallation = { *install(): Operation { yield* registerComponents([ @@ -647,7 +647,7 @@ describe("Tier SYN — the named form", () => { // 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 { installation: marker } = stating(symbolsOf("Marker")); const orders: string[] = []; for (const [first, second] of [ ["packages/one/components.md", "packages/two/components.md"], @@ -672,11 +672,52 @@ describe("Tier SYN — the named form", () => { expect(orders[0]).not.toBe(orders[1]); }); + it("SYN25l: bootstrapping one package twice refuses at collection", function* () { + // The exact repetition, which is what a profile that called one bootstrap + // twice produces: same owner, same component, same asset. Admitting it + // would say that assembly is valid, and this boundary cannot know what else + // the second call installed — a provider, a launcher, an execution policy. + // + // It has to refuse at *collection*, not in the named form's index. A + // document that writes bare `` never builds an index, and one + // that writes no `` at all never builds a reference either, so + // deferring would let both run to completion on an assembly nobody checked. + const { installation: marker } = stating(symbolsOf("Marker")); + const twice = (source: string): Operation => + scoped(function* () { + yield* useMarkerDocumentation(); + yield* useMarkerDocumentation(); + return yield* run(source, [marker]); + }); + + // Named, bare, and a document that writes no `` at all: the same + // refusal reaches all three, because collection happens for the execution + // rather than for an occurrence. + for (const source of ['\n', "\n", "nothing here\n"]) { + const refused = yield* refusal(twice(source)); + expect([source, refused.includes("contributes documentation for Marker twice")]).toEqual([ + source, + true, + ]); + expect(refused).toContain("packages/test/src/components.md"); + } + + // The positive control: one bootstrap of the same package is fine, and the + // no-`` document is not refused for some unrelated reason. + const once = String( + yield* scoped(function* () { + yield* useMarkerDocumentation(); + return yield* run('\n', [marker]); + }), + ); + expect(once).toContain("MARKER PROSE."); + }); + 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 { installation: marker } = stating(symbolsOf("Marker")); const inside = String( yield* scoped(function* () { yield* useMarkerDocumentation(); @@ -747,7 +788,7 @@ describe("Tier SYN — the named form", () => { // deno-lint-ignore require-yield *symbols(): Operation { calls.count += 1; - return catalogOf(`Marker${calls.count}`); + return symbolsOf(`Marker${calls.count}`); }, }; @@ -844,7 +885,7 @@ describe("Tier SYN — the name canonical core owns", () => { 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 { installation } = stating(symbolsOf("Marker")); const output = String( yield* run("\n\n", [installation], undefined, [dir]), @@ -1043,7 +1084,7 @@ describe("Tier SYN — what the chain may and may not do", () => { ); }, }; - const { installation } = stating(catalogOf("Marker")); + const { installation } = stating(symbolsOf("Marker")); expect(String(yield* run("\n", [installation, observing]))).toContain("Marker"); // The handler observed the import it could not answer. expect(seen).toContain(SYNTAX_COMPONENT); @@ -1077,7 +1118,7 @@ describe("Tier SYN — what the chain may and may not do", () => { ]; for (const [, answer] of cases) { - const { installation, calls } = stating(catalogOf("Marker")); + const { installation, calls } = stating(symbolsOf("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. @@ -1102,7 +1143,7 @@ describe("Tier SYN — what the chain may and may not do", () => { }, }; expect( - yield* refusal(run("\n", [stating(catalogOf("Marker")).installation, redirecting])), + yield* refusal(run("\n", [stating(symbolsOf("Marker")).installation, redirecting])), ).toBeTruthy(); const twice: ExecutionInstallation = { @@ -1124,7 +1165,7 @@ describe("Tier SYN — what the chain may and may not do", () => { // 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])), + yield* refusal(run("\n", [stating(symbolsOf("Marker")).installation, twice])), ).toBeTruthy(); }); @@ -1144,7 +1185,7 @@ describe("Tier SYN — what the chain may and may not do", () => { ); }, }; - const { installation, calls } = stating(catalogOf("Marker")); + const { installation, calls } = stating(symbolsOf("Marker")); expect(yield* refusal(run("\n", [installation, refusing]))).toContain( "this host refuses the catalog", ); @@ -1155,16 +1196,16 @@ describe("Tier SYN — what the chain may and may not do", () => { // 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")); + const { installation } = stating(symbolsOf("Marker")); const source = [ - '', + '', '', "{observed}", "", ].join("\n"); const output = String(yield* run(source, [installation])); expect(output).toContain("### ``"); - expect(output).not.toContain("a planted catalog"); + expect(output).not.toContain("planted symbols"); }); }); @@ -1188,8 +1229,8 @@ describe("Tier SYN — the site the catalog describes", () => { }); it("SYN27: the catalog reports a protected component as protected, not registered", function* () { - const { installation } = stating(catalogOf("Marker")); - const catalog = yield* scoped(function* () { + const { installation } = stating(symbolsOf("Marker")); + const symbols = yield* scoped(function* () { yield* executeInstalled( { ...retainedSource(ROOT_PATH, "\n"), @@ -1200,10 +1241,10 @@ describe("Tier SYN — the site the catalog describes", () => { ); return yield* inspectSyntax({ includes: [] }); }); - expect(catalog.version).toBe(2); + expect(symbols.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"); + const entry = symbols.categories[1].entries.find((candidate) => candidate.name === "Syntax"); if (entry === undefined) { throw new Error("expected the catalog to describe "); } @@ -1240,7 +1281,7 @@ describe("Tier SYN — the site the catalog describes", () => { // 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 symbols = yield* scoped(function* () { const registry = yield* Component.operations.registry; const workflow = installedBundle([bundle], registry); if (workflow === undefined) { @@ -1249,7 +1290,7 @@ describe("Tier SYN — the site the catalog describes", () => { return yield* inspectSyntax({ includes: [], workflow }); }); // User-provided: the third category. - const entry = catalog.categories[2].entries.find((candidate) => candidate.name === "Bundled"); + const entry = symbols.categories[2].entries.find((candidate) => candidate.name === "Bundled"); if (entry === undefined || entry.inspectability !== "complete") { throw new Error("expected the catalog to describe completely"); } @@ -1287,14 +1328,14 @@ describe("Tier SYN — the site the catalog describes", () => { }); it("SYN18: a declared Markdown component's own body observes the site it inherited", function* () { - const source = ['', "policy sees {catalog}", ""].join("\n"); + const source = ['', "policy sees {symbols}", ""].join("\n"); const declaration: DeclaredMarkdownComponent = { name: "Policy", origin: "@executablemd/test/Policy.md", source, digest: sourceDigest(source), }; - const { installation } = stating(catalogOf("Marker")); + const { installation } = stating(symbolsOf("Marker")); const output = String( yield* run("\n", [installation, { declarations: [declaration] }]), ); @@ -1306,8 +1347,8 @@ 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 { symbols }", function* () { const stream = new InMemoryStream(); - yield* run("\n", [stating(catalogOf("Marker")).installation], stream); - const [reference] = observations(yield* stream.readAll()); + yield* run("\n", [stating(symbolsOf("Marker")).installation], stream); + const [reference] = syntaxReads(yield* stream.readAll()); if (reference?.type !== "yield" || reference.result.status !== "ok") { throw new Error("the run retained no syntax record"); } @@ -1319,7 +1360,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { 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), + yield* run("\n", [stating(symbolsOf("Before")).installation], first), ); expect(before).toContain("### ``"); @@ -1338,7 +1379,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { // A fresh execution sees the moved environment, which is what shows the // restoration above was retention rather than the reference being inert. expect( - String(yield* run("\n", [stating(catalogOf("After")).installation])), + String(yield* run("\n", [stating(symbolsOf("After")).installation])), ).toContain("### ``"); }); @@ -1350,12 +1391,12 @@ describe("Tier SYN — the record one occurrence keeps", () => { ]; for (const [, replace] of cases) { const first = new InMemoryStream(); - yield* run("\n", [stating(catalogOf("Marker")).installation], first); + yield* run("\n", [stating(symbolsOf("Marker")).installation], first); const hostile = yield* tampered(first, replace); const refused = yield* refusal( run( - 'bound:{catalog}', - [stating(catalogOf("Marker")).installation], + 'bound:{symbols}', + [stating(symbolsOf("Marker")).installation], hostile, ), ); @@ -1387,7 +1428,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { // The structured teardown ran, and nothing successful was committed. expect(teardown).toEqual(["released"]); - const committed = observations(yield* stream.readAll()).filter( + const committed = syntaxReads(yield* stream.readAll()).filter( (event) => event.type === "yield" && event.result.status === "ok", ); expect(committed).toEqual([]); @@ -1398,7 +1439,7 @@ 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. - const { installation } = stating(catalogOf("Phantom")); + const { installation } = stating(symbolsOf("Phantom")); const output = String(yield* run("\n", [installation])); expect(output).toContain("### ``"); @@ -1410,8 +1451,8 @@ describe("Tier SYN — reference is never authority", () => { }); 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"); + const symbols = yield* inspectSyntax({ includes: [] }); + const entry = symbols.categories[1].entries.find((candidate) => candidate.name === "Syntax"); expect(entry).toBeDefined(); expect(entry?.description).toBe(DESCRIPTION); expect(entry?.forms).toEqual(["self-closing"]); @@ -1435,7 +1476,7 @@ describe("Tier SYN — reference is never authority", () => { }); expect(entry?.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); // Exactly one entry, in exactly one category. - const everywhere = catalog.categories.flatMap((category) => + const everywhere = symbols.categories.flatMap((category) => category.entries.filter((candidate) => candidate.name === "Syntax"), ); expect(everywhere.length).toBe(1); @@ -1468,7 +1509,7 @@ describe("Tier SYN — reference is never authority", () => { * the catalog and nothing more is this. */ it("SYN25b: a narrowed reference answers with exactly the catalog it was given", function* () { - const narrowed = catalogOf("Admitted"); + const narrowed = symbolsOf("Admitted"); 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 @@ -1484,8 +1525,8 @@ describe("Tier SYN — reference is never authority", () => { * Everything below is about them being genuinely two. */ it("SYN25c: a narrowed reference documents the enclosing site and marks availability", function* () { - const enclosing = catalogOf("Admitted", "Withheld"); - const narrowed = catalogOf("Admitted"); + const enclosing = symbolsOf("Admitted", "Withheld"); + const narrowed = symbolsOf("Admitted"); const reference = syntaxReference(narrowed, enclosing); // What may execute here is the narrowed catalog, and the bare form reports @@ -1610,7 +1651,7 @@ describe("Tier SYN — reference is never authority", () => { // list is execution-private and rebuilding an index from it is how two // indexes drift apart. const enclosing = yield* rootObservation(); - const admitted = catalogOf("Admitted"); + const admitted = symbolsOf("Admitted"); const narrowed = enclosing.available(admitted); // What may run is the admission. @@ -1641,7 +1682,7 @@ describe("Tier SYN — reference is never authority", () => { }; const contribution = { source, supplies }; const reference = syntaxReference( - catalogNamed("Alpha", { + symbolsNamed("Alpha", { kind: "registered", origin: "@executablemd/mutable", reserved: false, diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e95f5523..e4df1ef5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2809,7 +2809,7 @@ neighbouring one would keep that promise. Nothing else about the shape changed. 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 +bundle, the host or the lexical reference again; a missing, additional or mistyped member is stale input and refuses before output or binding. Two authored occurrences are two identities and two reads, repeated reads of one binding read nothing again, and a failed or cancelled read completes @@ -9869,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) | -| 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) | +| 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 reference 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 From 5c62cca2f365a41c7786b11a8cebfafd3733752c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:19:45 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9D=20State=20the=20documentation?= =?UTF-8?q?=20ownership=20boundary=20the=20implementation=20has?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Syntax section still said contributions "travel by value on the execution installation", describing the `ExecutionInstallation.documentation` field #758 removed. It now states what is implemented: a package bootstrap installs its registrations and additive `Documentation` middleware together, canonical core is the middleware terminal, and canonical execution collects the composed contributions once after trusted bootstrap and snapshots them by value before the root import. That captured snapshot — not a field on an installation — is what `xmd syntax` and an authored `` both consume. The duplicate rule is stated where it is enforced: any second contribution for one owning package and component refuses during collection, an exact repetition included, because collection is the only boundary every execution passes through. SYN25l joins the Tier SYN table with the three document shapes it covers and its positive control. The rest finishes the vocabulary audit this feature promised, in the focused specification section and the Syntax and `xmd syntax` test titles, helpers and comments. Uses of "observation" that name the import chain's own observing, the document-target catalog and the diagnostic catalog are other product concepts and are untouched. No runtime behavior changes. --- packages/cli/tests/syntax-cli.test.ts | 38 +++---- packages/core/tests/syntax-component.test.ts | 100 +++++++++---------- specs/executable-mdx-spec.md | 62 ++++++++---- 3 files changed, 109 insertions(+), 91 deletions(-) diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index a355b5f6..03a493a6 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -6,7 +6,7 @@ * about assembly, not about argv. The grammar, failure and delivery rows shell * out, so exit status, stdout and stderr are the ones an operator sees. * - * Catalog behavior itself is Tier SY's; nothing here re-proves selection. + * Symbol behavior itself is Tier SY's; nothing here re-proves selection. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -59,15 +59,15 @@ const WORKSPACE: Record = { "second/Only.md": "only in the second include\n", }; -function parseCatalog(text: string): SyntaxSymbols { +function parseSymbols(text: string): SyntaxSymbols { const parsed: unknown = JSON.parse(text); if (typeof parsed !== "object" || parsed === null) { - throw new Error("the catalog is not an object"); + throw new Error("the symbols are not an object"); } const version = Reflect.get(parsed, "version"); const categories = Reflect.get(parsed, "categories"); if (version !== 2 || !Array.isArray(categories) || categories.length !== 3) { - throw new Error("the catalog is not the version-2 shape"); + throw new Error("the symbols are not the version-2 shape"); } return { version, categories: readCategories(categories) }; } @@ -79,7 +79,7 @@ function readCategories(categories: unknown[]): SyntaxSymbols["categories"] { builtIn?.kind !== "built-in" || userProvided?.kind !== "user-provided" ) { - throw new Error("the catalog categories are not the fixed tuple"); + throw new Error("the symbol categories are not the fixed tuple"); } return [ { kind: "structural", entries: structural.entries }, @@ -91,11 +91,11 @@ function readCategories(categories: unknown[]): SyntaxSymbols["categories"] { // deno-lint-ignore no-explicit-any function readCategory(category: unknown): { kind: unknown; entries: any[] } { if (typeof category !== "object" || category === null) { - throw new Error("a catalog category is not an object"); + throw new Error("a symbol category is not an object"); } const entries = Reflect.get(category, "entries"); if (!Array.isArray(entries)) { - throw new Error("a catalog category has no entries"); + throw new Error("a symbol category has no entries"); } return { kind: Reflect.get(category, "kind"), entries }; } @@ -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): SyntaxSymbols { +function symbolsWith(props: PropsSchema): SyntaxSymbols { return { version: 2, categories: [ @@ -187,7 +187,7 @@ describe("Tier SX — the run profile the command describes", () => { const [entry] = everywhere; if (entry === undefined || entry.kind !== "component" || entry.inspectability !== "complete") { - throw new Error("the catalog describes without a contract"); + throw new Error("the symbols describe without a contract"); } expect(entry.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); expect(entry.sourceKind).toBe("protected"); @@ -346,7 +346,7 @@ describe("Tier SX — the renderers take a value", () => { it("SX4b: escapes a table cell that would otherwise shift the columns", function* () { const markdown = renderSyntaxMarkdown( - catalogWith({ + symbolsWith({ type: "object", properties: { "left|right": { type: "string", description: "a | in the description, and\na break" }, @@ -368,7 +368,7 @@ describe("Tier SX — the renderers take a value", () => { } }); - it("SX5: renders the same bytes twice from the same catalog", function* () { + it("SX5: renders the same bytes twice from the same symbols", function* () { const catalog = yield* syntaxSymbols([]); expect(renderSyntaxMarkdown(catalog)).toBe(renderSyntaxMarkdown(catalog)); @@ -396,7 +396,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources ["syntax", "--json", "--include", "first", "--include", "second"], { cwd }, ).expect(); - const forwardCatalog = parseCatalog(forward.stdout); + const forwardCatalog = parseSymbols(forward.stdout); expect(names(forwardCatalog.categories[2].entries)).toEqual(["Only", "Shared"]); const shared = forwardCatalog.categories[2].entries.find((one) => one.name === "Shared"); expect(shared?.origin).toEqual({ kind: "repository", path: "first/Shared.md" }); @@ -408,7 +408,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources ["syntax", "--json", "--include", "second", "--include", "first"], { cwd }, ).expect(); - const reversedShared = parseCatalog(reversed.stdout).categories[2].entries.find( + const reversedShared = parseSymbols(reversed.stdout).categories[2].entries.find( (one) => one.name === "Shared", ); expect(reversedShared?.origin).toEqual({ kind: "repository", path: "second/Shared.md" }); @@ -418,7 +418,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources it("SX8: falls back to components and . when no include is written", function* () { yield* useWorkspace(WORKSPACE, function* (cwd) { const { stdout } = yield* runCli(["syntax", "--json"], { cwd }).expect(); - const catalog = parseCatalog(stdout); + const catalog = parseSymbols(stdout); expect(names(catalog.categories[2].entries)).toContain("Default"); }); @@ -457,7 +457,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources ); }); - it("SX9: reports an unusable include on stderr, exits 1, and prints no catalog", function* () { + it("SX9: reports an unusable include on stderr, exits 1, and prints no symbols", function* () { yield* useWorkspace({ components: "not a directory\n" }, function* (cwd) { const { code, stdout, stderr } = yield* runCli(["syntax", "--include", "components"], { cwd, @@ -527,7 +527,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(markdown.stdout).toContain("from the first include."); const json = yield* runCli(["syntax", "--json", "--include", "first"], { cwd }).expect(); - const catalog = parseCatalog(json.stdout); + const catalog = parseSymbols(json.stdout); expect(catalog.version).toBe(2); expect(names(catalog.categories[2].entries)).toEqual(["Shared"]); }); @@ -558,13 +558,13 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(stderr).toBe(""); expect(code).toBe(0); - const catalog = parseCatalog(stdout); + const catalog = parseSymbols(stdout); expect(names(catalog.categories[2].entries)).toEqual(["Widget"]); }, ); }); - it("SX11: the catalog is inspection, and plan is the command that writes with it", function* () { + it("SX11: the symbols are inspection, and plan is the command that writes with them", function* () { const { stdout } = yield* runCli(["--help"]).expect(); expect(stdout).toContain("syntax"); @@ -608,7 +608,7 @@ describe( const { redirected, piped } = yield* deliveries(["--json"], cwd); expect(piped).toBe(redirected); - const catalog = parseCatalog(piped); + const catalog = parseSymbols(piped); 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/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 3662a158..39128f10 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -779,10 +779,10 @@ 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* () { + it("SYN49: a named root occurrence resolves its symbols 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. + // symbols and the availability beside it from another. const calls = { count: 0 }; const moving: ExecutionInstallation = { // deno-lint-ignore require-yield @@ -803,7 +803,7 @@ describe("Tier SYN — the named form", () => { expect(first).toContain("### ``"); expect(first).toContain("**Available in this evaluation:** yes"); - // Two occurrences still observe independently: this is one catalog per + // Two occurrences still read independently: this is one read per // occurrence, not one per execution. calls.count = 0; const both = String( @@ -830,7 +830,7 @@ describe("Tier SYN — the named form", () => { 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 + // Exactly what was rendered, not the compact list: the record is the // occurrence's final text whichever form produced it. const records = retained(yield* stream.readAll()); expect(records).toHaveLength(1); @@ -843,7 +843,7 @@ describe("Tier SYN — the named form", () => { 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 + // reread and the symbols are not rebuilt: what an agent was shown is what it // is shown again. const resumed = String( yield* run('\n', [], yield* continuing(stream)), @@ -856,13 +856,13 @@ describe("Tier SYN — the named form", () => { expect(refused).toContain("not a record this version can read"); }); - it("SYN31: refuses an unusable list before observing anything", function* () { + it("SYN31: refuses an unusable list before reading 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. + // back as the symbols. expect(retained(yield* stream.readAll())).toHaveLength(0); for (const written of [ @@ -883,7 +883,7 @@ describe("Tier SYN — the named form", () => { 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, "Syntax.md"), "repository symbols\n"); yield* writeTextFile(join(dir, "Nearby.md"), "a nearby repository component\n"); const { installation } = stating(symbolsOf("Marker")); @@ -892,7 +892,7 @@ describe("Tier SYN — the name canonical core owns", () => { ); // The protected component answered, and the repository file did not. expect(output).toContain("### ``"); - expect(output).not.toContain("a repository catalog"); + expect(output).not.toContain("repository symbols"); // 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"); @@ -901,7 +901,7 @@ describe("Tier SYN — the name canonical core owns", () => { 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"); + yield* writeTextFile(join(dir, "Syntax.md"), "repository symbols\n"); const selected = yield* selectComponent(SYNTAX_COMPONENT, { includes: [dir] }); expect(selected.kind).toBe("protected"); // Its own origin kind. Not a reserved registration: that is a host @@ -994,7 +994,7 @@ describe("Tier SYN — the name canonical core owns", () => { }); it("SYN9: a host that declares Markdown called Syntax is refused before the root import", function* () { - const source = "a declared catalog\n"; + const source = "declared symbols\n"; const declaration: DeclaredMarkdownComponent = { name: "Syntax", origin: "@executablemd/test/Syntax.md", @@ -1005,7 +1005,7 @@ describe("Tier SYN — the name canonical core owns", () => { 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. + // Before the root import: nothing was imported and nothing was read. const events = yield* stream.readAll(); expect(events.filter((event) => event.type === "yield").length).toBe(0); @@ -1018,7 +1018,7 @@ describe("Tier SYN — the name canonical core owns", () => { digest: sourceDigest(source), }; expect(String(yield* run("\n", [{ declarations: [adjacent] }]))).toContain( - "a declared catalog", + "declared symbols", ); }); @@ -1027,7 +1027,7 @@ describe("Tier SYN — the name canonical core owns", () => { name: "Syntax", path: "components/Syntax.md", sourceHash: "0".repeat(40), - content: "a bundled catalog\n", + content: "bundled symbols\n", }; const adjacent = { name: "Bundled", @@ -1071,7 +1071,7 @@ describe("Tier SYN — what the chain may and may not do", () => { it("SYN11: ordinary delegation reaches canonical Syntax", function* () { const seen: string[] = []; - const observing: ExecutionInstallation = { + const watching: ExecutionInstallation = { *install() { yield* Component.around( { @@ -1085,14 +1085,14 @@ describe("Tier SYN — what the chain may and may not do", () => { }, }; const { installation } = stating(symbolsOf("Marker")); - expect(String(yield* run("\n", [installation, observing]))).toContain("Marker"); + expect(String(yield* run("\n", [installation, watching]))).toContain("Marker"); // The handler observed the import it could not answer. expect(seen).toContain(SYNTAX_COMPONENT); }); it("SYN12: a handler that answers, substitutes, mutates or copies runs no replacement", function* () { const replacement: FunctionComponent = function* () { - return "a replaced catalog"; + return "replaced symbols"; }; const cases: [string, (real: ImportedDefinition) => ImportedDefinition][] = [ [ @@ -1121,7 +1121,7 @@ describe("Tier SYN — what the chain may and may not do", () => { const { installation, calls } = stating(symbolsOf("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. + // Refused before the body: no symbols were read for the replacement. expect(calls.count).toBe(0); } }); @@ -1176,7 +1176,7 @@ describe("Tier SYN — what the chain may and may not do", () => { { *importComponent([name, position], next) { if (name === SYNTAX_COMPONENT) { - throw new Error("this host refuses the catalog"); + throw new Error("this host refuses the symbols"); } return yield* next(name, position); }, @@ -1187,7 +1187,7 @@ describe("Tier SYN — what the chain may and may not do", () => { }; const { installation, calls } = stating(symbolsOf("Marker")); expect(yield* refusal(run("\n", [installation, refusing]))).toContain( - "this host refuses the catalog", + "this host refuses the symbols", ); expect(calls.count).toBe(0); }); @@ -1195,12 +1195,12 @@ describe("Tier SYN — what the chain may and may not do", () => { 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. + // bind, and the symbols are unchanged by both. const { installation } = stating(symbolsOf("Marker")); const source = [ '', - '', - "{observed}", + '', + "{read}", "", ].join("\n"); const output = String(yield* run(source, [installation])); @@ -1209,11 +1209,11 @@ describe("Tier SYN — what the chain may and may not do", () => { }); }); -describe("Tier SYN — the site the catalog describes", () => { - it("SYN16: the derived catalog reports this execution's own includes and registry", function* () { +describe("Tier SYN — the site the symbols describe", () => { + it("SYN16: the derived symbols report 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 + // No host contribution: canonical core derives the symbols from the // selection inputs this execution captured. const output = String(yield* run("\n", [], undefined, [dir])); expect(output).toContain("### ``"); @@ -1222,13 +1222,13 @@ describe("Tier SYN — the site the catalog describes", () => { expect(output).toContain(DESCRIPTION); // 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. + // the two phrases, so the symbols must not print the other one. expect(output).toContain("`@executablemd/core` (protected component)"); expect(output).not.toContain("reserved registration"); }); }); - it("SYN27: the catalog reports a protected component as protected, not registered", function* () { + it("SYN27: the symbols report a protected component as protected, not registered", function* () { const { installation } = stating(symbolsOf("Marker")); const symbols = yield* scoped(function* () { yield* executeInstalled( @@ -1246,7 +1246,7 @@ describe("Tier SYN — the site the catalog describes", () => { // Built-in: the second category, where a reader indexes for it. const entry = symbols.categories[1].entries.find((candidate) => candidate.name === "Syntax"); if (entry === undefined) { - throw new Error("expected the catalog to describe "); + throw new Error("expected the symbols to describe "); } // The structured origin, which is what a machine reader switches on. expect(entry.origin).toEqual({ kind: "protected", origin: "@executablemd/core" }); @@ -1292,7 +1292,7 @@ describe("Tier SYN — the site the catalog describes", () => { // User-provided: the third category. const entry = symbols.categories[2].entries.find((candidate) => candidate.name === "Bundled"); if (entry === undefined || entry.inspectability !== "complete") { - throw new Error("expected the catalog to describe completely"); + throw new Error("expected the symbols to describe completely"); } expect(entry.origin).toEqual({ kind: "workflow", @@ -1302,7 +1302,7 @@ describe("Tier SYN — the site the catalog describes", () => { expect(entry.sourceKind).toBe("workflow-markdown"); }); - it("SYN17: a workflow root observes its own bundle without running a member", function* () { + it("SYN17: a workflow root reports its own bundle without running a member", function* () { const entered: string[] = []; const bundle = { components: [ @@ -1327,7 +1327,7 @@ describe("Tier SYN — the site the catalog describes", () => { ).toBe(false); }); - it("SYN18: a declared Markdown component's own body observes the site it inherited", function* () { + it("SYN18: a declared Markdown component's own body reports the site it inherited", function* () { const source = ['', "policy sees {symbols}", ""].join("\n"); const declaration: DeclaredMarkdownComponent = { name: "Policy", @@ -1357,7 +1357,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { expect(typeof value.symbols).toBe("string"); }); - it("SYN20: a continuation restores the catalog after the environment moves, and asks nothing", function* () { + it("SYN20: a continuation restores the symbols after the environment moves, and asks nothing", function* () { const first = new InMemoryStream(); const before = String( yield* run("\n", [stating(symbolsOf("Before")).installation], first), @@ -1369,7 +1369,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { const moved: ExecutionInstallation = { // deno-lint-ignore require-yield *symbols(): Operation { - throw new Error("the continuation rebuilt the catalog"); + throw new Error("the continuation rebuilt the symbols"); }, }; const continued = String(yield* run("\n", [moved], yield* continuing(first))); @@ -1404,7 +1404,7 @@ describe("Tier SYN — the record one occurrence keeps", () => { } }); - it("SYN22: a cancelled reference tears down and commits no catalog", function* () { + it("SYN22: a cancelled reference tears down and commits no record", function* () { const teardown: string[] = []; const stream = new InMemoryStream(); const hanging: ExecutionInstallation = { @@ -1436,8 +1436,8 @@ describe("Tier SYN — the record one occurrence keeps", () => { }); 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 + it("SYN23: symbols naming a component neither register nor resolve it", function* () { + // The strongest form: the trusted host itself states symbols naming a // component nothing supplies. const { installation } = stating(symbolsOf("Phantom")); const output = String(yield* run("\n", [installation])); @@ -1503,25 +1503,25 @@ describe("Tier SYN — reference is never authority", () => { * * `` admits an exact vocabulary before it expands a generated * 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. + * admission's own symbols — it cannot add an entry the admission does not + * hold, because it is handed the symbols rather than asked to build one. * Installing it for an evaluation subtree is #713's; that the reference is - * the catalog and nothing more is this. + * the symbols and nothing more is this. */ - it("SYN25b: a narrowed reference answers with exactly the catalog it was given", function* () { + it("SYN25b: a narrowed reference answers with exactly the symbols it was given", function* () { const narrowed = symbolsOf("Admitted"); 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. + // is absent, because the symbols it was handed do not hold one. expect(yield* reference.symbols()).not.toContain("### ``"); }); /** * The seam #713 installs through, proved without an ``. * - * A narrowing boundary hands the reference two catalogs: what may execute - * in the subtree, and the enclosing authoring catalog selection reads from. + * A narrowing boundary hands the reference two sets of symbols: what may execute + * in the subtree, and the enclosing authoring symbols selection reads from. * Everything below is about them being genuinely two. */ it("SYN25c: a narrowed reference documents the enclosing site and marks availability", function* () { @@ -1529,13 +1529,13 @@ describe("Tier SYN — reference is never authority", () => { const narrowed = symbolsOf("Admitted"); const reference = syntaxReference(narrowed, enclosing); - // What may execute here is the narrowed catalog, and the bare form reports + // What may execute here is the narrowed set, and the bare form reports // exactly that. const available = yield* reference.symbols(); expect(available).toContain("### ``"); expect(available).not.toContain("### ``"); - // Reference material comes from the enclosing catalog, so a component this + // Reference material comes from the enclosing symbols, 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* reference.documentation(["Withheld"]); @@ -1547,7 +1547,7 @@ describe("Tier SYN — reference is never authority", () => { 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 + // A boundary that narrows nothing has one set, and everything in it is // available — the ordinary case. const open = syntaxReference(enclosing); expect(yield* open.documentation(["Withheld"])).toContain( @@ -1556,7 +1556,7 @@ describe("Tier SYN — reference is never authority", () => { }); it("SYN25d: availability compares the whole identity, not the spelling", function* () { - /** One catalog holding a single entry of exactly this identity. */ + /** Symbols holding a single entry of exactly this identity. */ const holding = (origin: NamedOrigin): SyntaxSymbols => ({ version: 2, categories: [ @@ -1646,7 +1646,7 @@ describe("Tier SYN — reference is never authority", () => { it("SYN25e: a narrowed reference is derived from the enclosing one", function* () { // The seam as an evaluator actually meets it: it holds the enclosing - // reference and an admitted catalog, and nothing else. No raw + // reference and an admitted set of symbols, 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. @@ -1705,7 +1705,7 @@ describe("Tier SYN — reference is never authority", () => { 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. + // reaching for symbols with nothing established would meet. const output = String( yield* collect( yield* execute({ diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e4df1ef5..4310c504 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2714,11 +2714,11 @@ 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 — 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. +contributed by that boundary's own bootstrap from the same declarations it +registers, so a component added to a package demands documentation without +anyone maintaining a second list. What `xmd syntax NAME` and a document's own +`` each read is the one snapshot canonical execution captured +at collection, which is what makes them 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 @@ -2741,20 +2741,37 @@ 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. -**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. +**Documentation composes with the components it describes.** A package's +bootstrap installs its registrations and its documentation together — the +registrations, and additive middleware on one stable, namespaced contextual Api +— so a host that bootstraps a package gets both from one call. Canonical core is +the middleware **terminal**: every chain ends there, so core's own components are +documented in an execution that bootstrapped no other package at all, and every +wrapper delegates before appending its own. Composition order therefore decides +how the list reads and nothing else. + +Canonical execution collects the composed contributions **once**, after the +trusted host's bootstrap and before the root import or any document code, and +snapshots the answer field by field. That captured snapshot — not a field on an +execution installation — is what `xmd syntax` and an authored `` both +consume. 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. + +**Any second contribution for one owning package and component refuses during +collection**, including an exact repetition naming the same asset — which is what +a profile that entered one package's bootstrap twice produces. Whether that +second call also installed a provider, a launcher or an execution policy is not a +question this boundary can answer, and the assembly is wrong either way. +Collection is where it refuses because it is the only boundary every execution +passes through: a document that writes bare `` builds no documentation +index, and one that writes no `` at all builds no reference either, so +deferring the check to named lookup would let both run to completion on an +assembly nobody validated. + +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 @@ -2805,7 +2822,7 @@ 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 +**Each occurrence reads once.** It claims the durable identity the execution 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 @@ -10911,7 +10928,7 @@ component that observes one at an authored site. | 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 | +| 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 reference 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 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 | @@ -10923,7 +10940,7 @@ component that observes one at an authored site. | 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 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 | +| SYN25c | The narrowing seam | A narrowed reference 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 | @@ -10933,6 +10950,7 @@ component that observes one at an authored site. | 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 | +| SYN25l | A repeated bootstrap refuses at collection | An identical repeated bootstrap — one package entered twice, naming the same component from the same asset — refuses for a named ``, for a bare ``, and for a document containing no `` at all, because collection happens for the execution rather than for an occurrence; one bootstrap of the same package is the positive control | | 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 | From 4b71a64345e5630464d289ec6ed0b9d132fdfa31 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:39:32 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=9A=91=20Let=20a=20package=20be=20boo?= =?UTF-8?q?tstrapped=20at=20more=20than=20one=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusing every second contribution broke `xmd workflow`. The repository-composition vocabulary is deliberately installed twice — once by an ordinary run's bootstrap and again inside the workflow attachment, because either may be the only one — and the attachment's scope descends from the run's, so both wrappers sit in one chain. Collection saw two identical contributions and refused, and seven CI shards failed on it. `installation.ts` says so in its own words: "One array, three consumers." The repeat is the product's layering, not a wrong assembly, and an identical contribution cannot change what a document is told — which is the thing the refusal exists to protect. So a bootstrap that finds its own statement already in the chain does not append it again. Two contributions are the same statement when they agree on the owning package, the asset and the exact set of components; differing in any of the three makes them two statements, and two statements about one component still refuse at collection. SYN25l now proves the repeat is idempotent — the named form, the bare form and a document with no `` all work, and the collected snapshot holds the contribution exactly once — with two bootstraps naming different assets as the control that keeps it from passing vacuously. The specification and architecture.md say the same. --- architecture.md | 21 ++++--- packages/core/src/documentation-api.ts | 61 +++++++++++++++----- packages/core/tests/syntax-component.test.ts | 60 ++++++++++--------- specs/executable-mdx-spec.md | 30 ++++++---- 4 files changed, 115 insertions(+), 57 deletions(-) diff --git a/architecture.md b/architecture.md index 8e63434b..a7bff718 100644 --- a/architecture.md +++ b/architecture.md @@ -3717,12 +3717,19 @@ 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 a document or component that composes around the Api afterwards composes into a chain nothing reads, two executions assembled in sibling scopes each read their -own, and a second contribution for one component of one package refuses at -collection — including an exact repetition, because a package bootstrapped twice -is a wrong assembly whichever asset the second call named. Collection is the -boundary that catches it because it is the only one every execution passes -through: a document that writes bare ``, or none at all, builds no -index and would otherwise run to completion on an assembly nobody validated. +own, and two contributions that *disagree* about one component of one package — +the same component from two different assets — refuse at collection. Collection +is the boundary that catches them because it is the only one every execution +passes through: a document that writes bare ``, or none at all, builds +no index and would otherwise run to completion on an assembly nobody validated. + +An identical repetition is not a disagreement. One package's declarative +vocabulary is deliberately installed at more than one layer — the +repository-composition set is bootstrapped by an ordinary run and again inside a +workflow attachment, because either may be the only one — and the inner scope +descends from the outer, so both wrappers sit in one chain. A bootstrap that +finds its own statement already there does not append it again, so the component +is documented exactly once and the layering is not a failure. 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 @@ -4010,7 +4017,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-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 a second contribution for one component of one package refuses at collection — whichever order they were bootstrapped in, and including an exact repetition. 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 | +| `` | 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 that disagree about one component of one package — the same component from two different assets — refuse at collection whichever order they were bootstrapped in, while an identical repetition is a no-op because one package is deliberately bootstrapped at more than one layer. 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 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) | diff --git a/packages/core/src/documentation-api.ts b/packages/core/src/documentation-api.ts index d7bfb38f..5ca76232 100644 --- a/packages/core/src/documentation-api.ts +++ b/packages/core/src/documentation-api.ts @@ -99,17 +99,47 @@ export const Documentation: Api = createApi( * 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. + * + * **An identical contribution already in the chain is not appended twice.** One + * package's declarative vocabulary is deliberately installed at more than one + * layer — the repository-composition set is registered by an ordinary run's + * bootstrap *and* again inside a workflow attachment, because either may be the + * only one — and the inner scope descends from the outer, so both wrappers are + * in one chain. Appending the second would refuse at collection, which would + * turn the product's own layering into a failure. Contributing the same + * component from the same asset says exactly what the first one said, so + * repeating it is a no-op rather than a conflict; two *different* assets still + * refuse at collection, because those disagree. */ export function* contributeDocumentation( contribute: (read: DocumentationReader) => Operation, ): Operation { yield* Documentation.around({ *contributions([read], next): Operation { - return [...(yield* next(read)), yield* contribute(read)]; + const enclosing = yield* next(read); + const mine = yield* contribute(read); + return enclosing.some((one) => identical(one, mine)) ? enclosing : [...enclosing, mine]; }, }); } +/** + * Whether two contributions say the same thing. + * + * Owner, asset and the exact set of names — everything the index joins on and + * everything collection refuses over. A pair agreeing on all three is one + * statement made twice; a pair differing in any is two statements, and which of + * those it is decides whether the repeat is a layering or a conflict. + */ +function identical(one: DocumentationContribution, other: DocumentationContribution): boolean { + return ( + one.source.owner === other.source.owner && + one.source.asset === other.source.asset && + one.supplies.size === other.supplies.size && + [...one.supplies].every((name) => other.supplies.has(name)) + ); +} + /** * What this execution's packages contributed, captured by value. * @@ -124,15 +154,16 @@ export function* contributeDocumentation( * document is told about a component depend on the order its host happened to * bootstrap packages in. * - * **Including an exact repetition.** Two bootstraps of one package name the - * same component from the same asset, and admitting that would say a profile - * which bootstrapped a package twice is a valid profile. It is not: whether the - * second call also installed a provider, a launcher or an execution policy is - * not a question this boundary can answer, and the assembly is wrong either - * way. Collection is where that is caught, because it is the only boundary - * every execution passes through — deferring it to the named form's index - * would let a document that writes bare ``, or no `` at all, - * run to completion on an assembly nobody validated. + * What reaches here is therefore a genuine disagreement: two *different* assets + * claiming one component of one package. An identical repetition never arrives, + * because `contributeDocumentation()` recognizes its own statement already in + * the chain and does not append it twice — that is the product's own layering, + * not a conflict. + * + * Collection is where a disagreement is caught, because it is the only boundary + * every execution passes through. Deferring it to the named form's index would + * let a document that writes bare ``, or no `` at all, run to + * completion on an assembly nobody validated. */ export function* capturedDocumentation( read: DocumentationReader = packagedAssetReader, @@ -147,9 +178,13 @@ export function* capturedDocumentation( if (first !== undefined) { throw new DocumentationIndexError( first === one.source.asset - ? `${owner} contributes documentation for ${name} twice, both times from ` + - `${one.source.asset}. A package is bootstrapped once per execution: a second ` + - "contribution means the assembly installed it twice." + ? // Same asset, but the contributions were not identical — otherwise + // one of them would not be here. So two bootstraps disagree about + // which components that one file accounts for. + `${owner} contributes documentation for ${name} twice from ` + + `${one.source.asset}, in two contributions that name different components. ` + + "One asset accounts for one set of components, however many bootstraps " + + "installed it." : `${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.", diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 39128f10..9befd7c9 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -672,16 +672,16 @@ describe("Tier SYN — the named form", () => { expect(orders[0]).not.toBe(orders[1]); }); - it("SYN25l: bootstrapping one package twice refuses at collection", function* () { - // The exact repetition, which is what a profile that called one bootstrap - // twice produces: same owner, same component, same asset. Admitting it - // would say that assembly is valid, and this boundary cannot know what else - // the second call installed — a provider, a launcher, an execution policy. + it("SYN25l: bootstrapping one package twice is idempotent, not a conflict", function* () { + // One package's declarative vocabulary is deliberately installed at more + // than one layer — the repository-composition set is bootstrapped by an + // ordinary run *and* again inside a workflow attachment, because either may + // be the only one — and the inner scope descends from the outer, so both + // wrappers sit in one chain. The second says exactly what the first said, + // so it is not appended: repeating a statement is not disagreeing with it. // - // It has to refuse at *collection*, not in the named form's index. A - // document that writes bare `` never builds an index, and one - // that writes no `` at all never builds a reference either, so - // deferring would let both run to completion on an assembly nobody checked. + // Refusing it instead would turn the product's own layering into a failure, + // which is what `xmd workflow` demonstrated. const { installation: marker } = stating(symbolsOf("Marker")); const twice = (source: string): Operation => scoped(function* () { @@ -690,27 +690,35 @@ describe("Tier SYN — the named form", () => { return yield* run(source, [marker]); }); - // Named, bare, and a document that writes no `` at all: the same - // refusal reaches all three, because collection happens for the execution - // rather than for an occurrence. - for (const source of ['\n', "\n", "nothing here\n"]) { - const refused = yield* refusal(twice(source)); - expect([source, refused.includes("contributes documentation for Marker twice")]).toEqual([ - source, - true, - ]); - expect(refused).toContain("packages/test/src/components.md"); - } + // Named, bare, and a document that writes no `` at all. Collection + // happens for the execution rather than for an occurrence, so if a repeat + // were going to break anything it would break all three. + expect(String(yield* twice('\n'))).toContain("MARKER PROSE."); + expect(String(yield* twice("\n"))).toContain("### ``"); + expect(String(yield* twice("nothing here\n"))).toContain("nothing here"); + + // Documented exactly once, not twice: idempotent means the repeat left no + // second copy behind, which reading the collected snapshot shows directly. + const collected = yield* scoped(function* () { + yield* useMarkerDocumentation(); + yield* useMarkerDocumentation(); + return yield* capturedDocumentation(); + }); + expect( + collected.filter((one) => one.source.asset === "packages/test/src/components.md"), + ).toHaveLength(1); - // The positive control: one bootstrap of the same package is fine, and the - // no-`` document is not refused for some unrelated reason. - const once = String( - yield* scoped(function* () { + // And the control that keeps this from passing vacuously: two bootstraps + // that genuinely disagree — the same component from a different asset — + // still refuse at collection. + const conflicting = yield* refusal( + scoped(function* () { yield* useMarkerDocumentation(); - return yield* run('\n', [marker]); + yield* useMarkerDocumentation("packages/other/src/components.md"); + return yield* run("\n", [marker]); }), ); - expect(once).toContain("MARKER PROSE."); + expect(conflicting).toContain("contributes documentation for Marker from both"); }); it("SYN25j: two scopes each read their own contributions", function* () { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 4310c504..9b8a2fde 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2758,16 +2758,24 @@ consume. 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. -**Any second contribution for one owning package and component refuses during -collection**, including an exact repetition naming the same asset — which is what -a profile that entered one package's bootstrap twice produces. Whether that -second call also installed a provider, a launcher or an execution policy is not a -question this boundary can answer, and the assembly is wrong either way. -Collection is where it refuses because it is the only boundary every execution -passes through: a document that writes bare `` builds no documentation -index, and one that writes no `` at all builds no reference either, so -deferring the check to named lookup would let both run to completion on an -assembly nobody validated. +**Two contributions that disagree about one component of one package refuse +during collection** — the same component named from two different assets, in +either order. Collection is where they refuse because it is the only boundary +every execution passes through: a document that writes bare `` builds +no documentation index, and one that writes no `` at all builds no +reference either, so deferring the check to named lookup would let both run to +completion on an assembly nobody validated. + +**An identical repetition is not a disagreement.** One package's declarative +vocabulary is deliberately installed at more than one layer — the +repository-composition set is bootstrapped by an ordinary run and again inside a +workflow attachment, because either may be the only one — and the inner scope +descends from the outer, so both wrappers sit in one chain. A bootstrap that +finds its own statement already there does not append it again, so the component +is documented exactly once and the layering is not a failure. What makes two +contributions the same statement is the owning package, the asset, and the exact +set of components; differing in any of the three makes them two statements, and +two statements about one component refuse. 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; @@ -10950,7 +10958,7 @@ component that observes one at an authored site. | 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 | -| SYN25l | A repeated bootstrap refuses at collection | An identical repeated bootstrap — one package entered twice, naming the same component from the same asset — refuses for a named ``, for a bare ``, and for a document containing no `` at all, because collection happens for the execution rather than for an occurrence; one bootstrap of the same package is the positive control | +| SYN25l | A repeated bootstrap is idempotent | An identical repeated bootstrap — one package entered twice, naming the same components from the same asset — leaves a named ``, a bare `` and a document containing no `` at all each working, and the collected snapshot holds that contribution exactly once; two bootstraps naming one component from *different* assets still refuse, which is what keeps this from passing vacuously | | 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 | From b951c835d6a8818f034a7ef6fae4e6321e4b09c9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:55:02 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Compare=20a=20document?= =?UTF-8?q?ation=20contribution=20by=20all=20four=20of=20its=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coalescing on owner, asset and component set left the documentation text out, so two bootstraps naming one path and reading different bytes — a stale build tree, a substituted reader — would have coalesced silently and kept whichever ran first. Equality now covers all four values a contribution is: owning package, asset identity, exact text, and component-name set. By value, so a fresh object each ask and a name set built in a different order are still the same statement. What reaches collection is therefore a real disagreement, and its refusal says which of the three differs rather than guessing. Two owners documenting a same-spelled component still coexist, because documentation joins by name and origin; so do disjoint sets from one owner. SYN25l becomes five rows: value-identical repetition adds nothing for the named form, the bare form and a document with no ``, with one bootstrap capturing exactly what two capture; a changed asset, text or overlapping set refuses in either order; distinct owner and disjoint set are the controls that keep that from over-refusing; overlapping executions stay isolated; and a layered trusted bootstrap keeps every layer's registrations *and* one documentation value. The specification and architecture.md state the four values, the coalescing rule, and the two non-conflicts. --- architecture.md | 30 ++- packages/core/src/documentation-api.ts | 117 +++++---- packages/core/tests/syntax-component.test.ts | 241 ++++++++++++++++--- specs/executable-mdx-spec.md | 60 +++-- 4 files changed, 337 insertions(+), 111 deletions(-) diff --git a/architecture.md b/architecture.md index a7bff718..0d3e8891 100644 --- a/architecture.md +++ b/architecture.md @@ -3717,19 +3717,29 @@ 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 a document or component that composes around the Api afterwards composes into a chain nothing reads, two executions assembled in sibling scopes each read their -own, and two contributions that *disagree* about one component of one package — -the same component from two different assets — refuse at collection. Collection +own, and two contributions that *disagree* about one owning package and +component name refuse at collection, whichever order supplied them. Collection is the boundary that catches them because it is the only one every execution passes through: a document that writes bare ``, or none at all, builds no index and would otherwise run to completion on an assembly nobody validated. -An identical repetition is not a disagreement. One package's declarative -vocabulary is deliberately installed at more than one layer — the -repository-composition set is bootstrapped by an ordinary run and again inside a -workflow attachment, because either may be the only one — and the inner scope -descends from the outer, so both wrappers sit in one chain. A bootstrap that -finds its own statement already there does not append it again, so the component -is documented exactly once and the layering is not a failure. +**A contribution is four values** — the owning package, the asset identity, the +exact documentation text and the component-name set — and equality covers all +four, by value rather than by object identity, with a name set carrying no +order. A repetition of that same value adds nothing and succeeds: one package's +declarative vocabulary is deliberately entered at more than one layer, the +repository-composition set by an ordinary run's bootstrap and again inside a +workflow attachment because either may be the only one, and a nested run or +evaluation host the same way. The inner scope descends from the outer, so both +wrappers sit in one chain; the second finds its own value already there and does +not append it, keeping its registrations and one documentation value. Comparing +the asset alone would instead coalesce two bootstraps that genuinely disagree +and silently keep whichever ran first. + +A different owner is unequal but is not by itself a conflict, because +documentation joins by component name *and* origin — two packages may each +document a same-spelled component. Disjoint component sets from one owner +coexist for the same reason. 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 @@ -4017,7 +4027,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-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 that disagree about one component of one package — the same component from two different assets — refuse at collection whichever order they were bootstrapped in, while an identical repetition is a no-op because one package is deliberately bootstrapped at more than one layer. 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 | +| `` | 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 that disagree about one owning package and component name refuse at collection whichever order they were bootstrapped in, while a repetition of the same four values — owner, asset, exact text and component set — adds nothing and succeeds, because one package is deliberately bootstrapped at more than one layer. 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 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) | diff --git a/packages/core/src/documentation-api.ts b/packages/core/src/documentation-api.ts index 5ca76232..0e6d4733 100644 --- a/packages/core/src/documentation-api.ts +++ b/packages/core/src/documentation-api.ts @@ -100,16 +100,20 @@ export const Documentation: Api = createApi( * and through the reader the collector supplies: a bootstrap installed in one * execution's scope reads that execution's assets. * - * **An identical contribution already in the chain is not appended twice.** One - * package's declarative vocabulary is deliberately installed at more than one - * layer — the repository-composition set is registered by an ordinary run's - * bootstrap *and* again inside a workflow attachment, because either may be the - * only one — and the inner scope descends from the outer, so both wrappers are - * in one chain. Appending the second would refuse at collection, which would - * turn the product's own layering into a failure. Contributing the same - * component from the same asset says exactly what the first one said, so - * repeating it is a no-op rather than a conflict; two *different* assets still - * refuse at collection, because those disagree. + * **A contribution of the same value already in the chain is not appended + * twice.** One package's declarative vocabulary is deliberately entered at more + * than one layer — the repository-composition set by an ordinary run's bootstrap + * *and* again inside a workflow attachment, because either may be the only one; + * a nested run or evaluation host the same way — and the inner scope descends + * from the outer, so both wrappers are in one chain. Appending the second would + * refuse at collection, which turns valid layering into a failure while there is + * no ambiguity to resolve: a repetition of the same value says exactly what the + * first one said, and there is no winner to pick. So it coalesces, and the + * repeated bootstrap keeps both its registrations and one documentation value. + * + * Equality is by value over all four of {@link identical}'s inputs. Anything + * short of that — matching on the asset alone, say — would coalesce two + * bootstraps that genuinely disagree, and silently keep whichever ran first. */ export function* contributeDocumentation( contribute: (read: DocumentationReader) => Operation, @@ -124,22 +128,33 @@ export function* contributeDocumentation( } /** - * Whether two contributions say the same thing. - * - * Owner, asset and the exact set of names — everything the index joins on and - * everything collection refuses over. A pair agreeing on all three is one - * statement made twice; a pair differing in any is two statements, and which of - * those it is decides whether the repeat is a layering or a conflict. + * Whether two contributions are the same value. + * + * All four of what a contribution *is*: the owning package, the asset identity, + * the exact documentation text, and the set of components it accounts for. The + * text is not redundant with the asset — two bootstraps can name one path and + * read different bytes, from a stale build tree or a substituted reader — and + * coalescing those would pick a winner silently, which is the whole thing this + * boundary exists to prevent. + * + * Compared by value, not by identity: each bootstrap builds a fresh object, and + * a `Set` has no order, so two contributions listing the same names in different + * orders are the same statement. */ function identical(one: DocumentationContribution, other: DocumentationContribution): boolean { return ( one.source.owner === other.source.owner && one.source.asset === other.source.asset && - one.supplies.size === other.supplies.size && - [...one.supplies].every((name) => other.supplies.has(name)) + one.source.text === other.source.text && + sameNames(one.supplies, other.supplies) ); } +/** Two name sets holding the same names, whatever order they were built in. */ +function sameNames(one: ReadonlySet, other: ReadonlySet): boolean { + return one.size === other.size && [...one].every((name) => other.has(name)); +} + /** * What this execution's packages contributed, captured by value. * @@ -149,16 +164,18 @@ function identical(one: DocumentationContribution, other: DocumentationContribut * 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. + * Two contributions that *disagree* about 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. * - * What reaches here is therefore a genuine disagreement: two *different* assets - * claiming one component of one package. An identical repetition never arrives, - * because `contributeDocumentation()` recognizes its own statement already in - * the chain and does not append it twice — that is the product's own layering, - * not a conflict. + * Only a disagreement reaches this far: a repetition of the same value never + * arrives, because `contributeDocumentation()` recognizes its own statement in + * the chain and does not append it twice. What is left overlaps on one owning + * package and one component name while differing in asset, text or the set of + * components accounted for. Two *different owners* documenting a same-spelled + * component is not a conflict — documentation joins by name and origin — and + * neither is one owner accounting for disjoint sets from two files. * * Collection is where a disagreement is caught, because it is the only boundary * every execution passes through. Deferring it to the named form's index would @@ -169,29 +186,45 @@ export function* capturedDocumentation( read: DocumentationReader = packagedAssetReader, ): Operation { const contributed = yield* Documentation.operations.contributions(read); - const seen = new Map(); + // Keyed by owning package *and* component name, because that pair is what + // documentation joins on. Two packages may document same-spelled components, + // and one package may account for disjoint sets from two files; neither is a + // question anyone has to answer, so neither is a conflict. + 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); + const first = seen.get(`${owner} ${name}`); if (first !== undefined) { throw new DocumentationIndexError( - first === one.source.asset - ? // Same asset, but the contributions were not identical — otherwise - // one of them would not be here. So two bootstraps disagree about - // which components that one file accounts for. - `${owner} contributes documentation for ${name} twice from ` + - `${one.source.asset}, in two contributions that name different components. ` + - "One asset accounts for one set of components, however many bootstraps " + - "installed it." - : `${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.", + `${owner} contributes documentation for ${name} twice, and the two ` + + `contributions are not the same: ${difference(first, one)}. One component of ` + + "one package has one documentation value, whichever order the bootstraps " + + "supplied it — so there is no winner to pick.", ); } - seen.set(key, one.source.asset); + seen.set(`${owner} ${name}`, one); } } return snapshotContributions(contributed); } + +/** + * What two conflicting contributions disagree about, for the refusal to name. + * + * An identical pair never reaches this — `contributeDocumentation()` coalesces + * those — so at least one of the three differs, and saying which is the + * difference between a diagnosis and a complaint. + */ +function difference(first: DocumentationContribution, second: DocumentationContribution): string { + if (first.source.asset !== second.source.asset) { + return `they name different assets, ${first.source.asset} and ${second.source.asset}`; + } + if (first.source.text !== second.source.text) { + return `both name ${first.source.asset}, but its text differs between them`; + } + return ( + `both name ${first.source.asset} with the same text, but they account for ` + + "different components" + ); +} diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 9befd7c9..8ba3fe50 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -45,6 +45,7 @@ import type { DeclaredMarkdownComponent, ExecutionInstallation } from "../host.t import { inspectComponent, inspectSyntax } from "../src/inspect.ts"; import { validateDocumentStructure } from "../src/document-validation.ts"; import { registerComponents } from "../src/components/registration.ts"; +import type { ComponentRegistration } 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"; @@ -112,20 +113,67 @@ function stating(symbols: SyntaxSymbols, calls: { count: number } = { count: 0 } * 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 { +function useMarkerDocumentation( + /** + * Whichever of the four values a case wants to vary. + * + * All four are what a contribution *is*, so a case about equality has to be + * able to change exactly one and leave the rest alone. A fresh object is built + * on every ask, so nothing here can pass by object identity. + */ + vary: { + owner?: string; + asset?: string; + text?: string; + supplies?: readonly string[]; + } = {}, +): Operation { // deno-lint-ignore require-yield return contributeDocumentation(function* () { return { source: { - owner: "@executablemd/test", - asset, - text: "## Marker\n\nMARKER PROSE.\n", + owner: vary.owner ?? "@executablemd/test", + asset: vary.asset ?? "packages/test/src/components.md", + text: vary.text ?? "## Marker\n\nMARKER PROSE.\n", }, - supplies: new Set(["Marker"]), + supplies: new Set(vary.supplies ?? ["Marker"]), }; }); } +/** Prose for a contribution accounting for two components at once. */ +const PAIR_PROSE = "## Marker\n\nMARKER PROSE.\n\n## Other\n\nOTHER PROSE.\n"; + +/** + * A collected list as its values, for comparing two collections. + * + * Object identity says nothing here — every ask builds fresh objects — and a + * `Set` has no order, so the names are sorted. What is left is exactly the four + * values equality is defined over. + */ +function describeContributions( + contributions: readonly DocumentationContribution[], +): readonly string[] { + return contributions.map((one) => + [one.source.owner, one.source.asset, one.source.text, [...one.supplies].sort().join(",")].join( + " | ", + ), + ); +} + +/** A trivial registered component, for proving a layer kept its registrations. */ +function layeredComponent(which: string): ComponentRegistration { + return { + name: which === "outer" ? "Outer" : "Inner", + origin: "@executablemd/test", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return `${which} component ran`; + }, + }; +} + /** Run one root, with whatever installations the case supplies. */ function run( source: string, @@ -656,15 +704,15 @@ describe("Tier SYN — the named form", () => { orders.push( yield* refusal( scoped(function* () { - yield* useMarkerDocumentation(first); - yield* useMarkerDocumentation(second); + yield* useMarkerDocumentation({ asset: first }); + yield* useMarkerDocumentation({ asset: second }); return yield* run('\n', [marker]); }), ), ); } for (const refused of orders) { - expect(refused).toContain("contributes documentation for Marker from both"); + expect(refused).toContain("they name different assets"); } // 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 @@ -672,53 +720,166 @@ describe("Tier SYN — the named form", () => { expect(orders[0]).not.toBe(orders[1]); }); - it("SYN25l: bootstrapping one package twice is idempotent, not a conflict", function* () { - // One package's declarative vocabulary is deliberately installed at more - // than one layer — the repository-composition set is bootstrapped by an - // ordinary run *and* again inside a workflow attachment, because either may - // be the only one — and the inner scope descends from the outer, so both - // wrappers sit in one chain. The second says exactly what the first said, - // so it is not appended: repeating a statement is not disagreeing with it. + it("SYN25l.1: a value-identical repeated bootstrap contributes nothing new", function* () { + // One package's declarative vocabulary is deliberately entered at more than + // one layer — the repository-composition set by an ordinary run's bootstrap + // and again inside a workflow attachment, because either may be the only + // one — and the inner scope descends from the outer, so both wrappers sit + // in one chain. Refusing the repeat turns valid layering into a failure + // while there is nothing ambiguous to resolve. // - // Refusing it instead would turn the product's own layering into a failure, - // which is what `xmd workflow` demonstrated. - const { installation: marker } = stating(symbolsOf("Marker")); + // Every ask builds a fresh object and the second names its components in + // the opposite order, so what passes here is value equality: object + // identity and set insertion order are both ruled out. + const { installation: marker } = stating(symbolsOf("Marker", "Other")); const twice = (source: string): Operation => scoped(function* () { - yield* useMarkerDocumentation(); - yield* useMarkerDocumentation(); + yield* useMarkerDocumentation({ supplies: ["Marker", "Other"], text: PAIR_PROSE }); + yield* useMarkerDocumentation({ supplies: ["Other", "Marker"], text: PAIR_PROSE }); return yield* run(source, [marker]); }); // Named, bare, and a document that writes no `` at all. Collection - // happens for the execution rather than for an occurrence, so if a repeat - // were going to break anything it would break all three. + // happens for the execution rather than for an occurrence, so a repeat that + // broke anything would break all three. expect(String(yield* twice('\n'))).toContain("MARKER PROSE."); expect(String(yield* twice("\n"))).toContain("### ``"); expect(String(yield* twice("nothing here\n"))).toContain("nothing here"); - // Documented exactly once, not twice: idempotent means the repeat left no - // second copy behind, which reading the collected snapshot shows directly. - const collected = yield* scoped(function* () { - yield* useMarkerDocumentation(); - yield* useMarkerDocumentation(); - return yield* capturedDocumentation(); - }); - expect( - collected.filter((one) => one.source.asset === "packages/test/src/components.md"), - ).toHaveLength(1); - - // And the control that keeps this from passing vacuously: two bootstraps - // that genuinely disagree — the same component from a different asset — - // still refuse at collection. - const conflicting = yield* refusal( + // And one bootstrap produces the same documentation as two, which is what + // "contributes nothing new" means: not merely that the repeat is tolerated, + // but that it leaves the captured value unchanged. + const captured = (repeats: number): Operation => scoped(function* () { + for (let entered = 0; entered < repeats; entered += 1) { + yield* useMarkerDocumentation({ supplies: ["Marker", "Other"], text: PAIR_PROSE }); + } + return yield* capturedDocumentation(); + }); + expect(describeContributions(yield* captured(2))).toEqual( + describeContributions(yield* captured(1)), + ); + }); + + it("SYN25l.2: a changed asset, text or component set refuses, in either order", function* () { + // The three ways two contributions can overlap on one owner and component + // while disagreeing. Each refuses, and each refuses whichever order the + // bootstraps ran in — so no order picks a winner. + const { installation: marker } = stating(symbolsOf("Marker")); + const conflicts: [string, Parameters[0]][] = [ + ["a changed asset", { asset: "packages/other/src/components.md" }], + ["changed text", { text: "## Marker\n\nSUBSTITUTED PROSE.\n" }], + ["an overlapping but different set", { supplies: ["Marker", "Extra"] }], + ]; + + for (const [what, changed] of conflicts) { + for (const reversed of [false, true]) { + const refused = yield* refusal( + scoped(function* () { + yield* useMarkerDocumentation(reversed ? changed : {}); + yield* useMarkerDocumentation(reversed ? {} : changed); + // No `` at all: the refusal is the execution's, before its + // root ran, not something an occurrence discovered. + return yield* run("nothing here\n", [marker]); + }), + ); + expect([ + what, + reversed, + refused.includes("contributes documentation for Marker twice"), + ]).toEqual([what, reversed, true]); + } + } + }); + + it("SYN25l.3: a distinct owner and a disjoint set are not conflicts", function* () { + // Documentation joins by component name *and* origin, so two packages may + // each document a component spelled `Marker`: neither answers for the + // other's, and there is no question for anyone to arbitrate. One owner + // accounting for disjoint sets from two files is the same story. + // + // These are the controls that keep SYN25l.2 from passing for the wrong + // reason — a rule that refused any repeated *name* would fail here. + const { installation: marker } = stating(symbolsOf("Marker")); + const rendered = String( + yield* scoped(function* () { + yield* useMarkerDocumentation(); + yield* useMarkerDocumentation({ + owner: "@executablemd/other", + asset: "packages/other/src/components.md", + text: "## Marker\n\nSOMEBODY ELSE'S MARKER.\n", + }); + yield* useMarkerDocumentation({ + asset: "packages/test/src/more.md", + text: "## Separate\n\nA DISJOINT SET.\n", + supplies: ["Separate"], + }); + return yield* run('\n', [marker]); + }), + ); + // The execution ran, and the entry took its own owner's prose — not the + // other package's, which documents a different component of the same name. + expect(rendered).toContain("MARKER PROSE."); + expect(rendered).not.toContain("SOMEBODY ELSE'S MARKER"); + }); + + it("SYN25l.4: overlapping executions each keep their own contributions", function* () { + // Collection is execution-scoped, so a conflict in one assembly is not a + // fact about another running beside it. The first scope is assembled with a + // genuine conflict and refuses; the second, live at the same time, is + // unaffected and renders its own. + const { installation: marker } = stating(symbolsOf("Marker")); + // Spawned in the case's own scope rather than a nested one, which would + // close and halt it: the point is that the two are live at the same time. + const conflicted = yield* spawn(() => + refusal( + scoped(function* () { + yield* useMarkerDocumentation(); + yield* useMarkerDocumentation({ text: "## Marker\n\nCONFLICTING.\n" }); + return yield* run("nothing here\n", [marker]); + }), + ), + ); + + const beside = String( + yield* scoped(function* () { yield* useMarkerDocumentation(); - yield* useMarkerDocumentation("packages/other/src/components.md"); - return yield* run("\n", [marker]); + return yield* run('\n', [marker]); }), ); - expect(conflicting).toContain("contributes documentation for Marker from both"); + expect(beside).toContain("MARKER PROSE."); + expect(beside).not.toContain("CONFLICTING"); + expect(yield* conflicted).toContain("contributes documentation for Marker twice"); + }); + + it("SYN25l.5: layered trusted bootstraps keep their registrations and documentation", function* () { + // The shape a nested run and an `` host both take: an outer + // trusted layer enters a package's bootstrap, an inner one enters the same + // package again, and the inner scope descends from the outer. Both halves + // have to survive — the registrations *and* the documentation — because + // losing either is how the two-list design failed in the first place. + const outerThenInner = yield* scoped(function* () { + yield* useMarkerDocumentation(); + yield* registerComponents([layeredComponent("outer")]); + return yield* scoped(function* () { + // The inner layer enters the same package bootstrap, and adds a + // registration of its own. + yield* useMarkerDocumentation(); + yield* registerComponents([layeredComponent("inner")]); + return yield* run('\n\n\n\n\n', [ + stating(symbolsOf("Marker")).installation, + ]); + }); + }); + + const rendered = String(outerThenInner); + // Both registrations resolve: entering the bootstrap twice took nothing + // away from the enclosing layer. + expect(rendered).toContain("outer component ran"); + expect(rendered).toContain("inner component ran"); + // And the documentation is there exactly once, from one coalesced value. + expect(rendered).toContain("MARKER PROSE."); + expect(rendered.match(/MARKER PROSE\./g)).toHaveLength(1); }); it("SYN25j: two scopes each read their own contributions", function* () { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9b8a2fde..2ed504ed 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2758,24 +2758,42 @@ consume. 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. -**Two contributions that disagree about one component of one package refuse -during collection** — the same component named from two different assets, in -either order. Collection is where they refuse because it is the only boundary -every execution passes through: a document that writes bare `` builds -no documentation index, and one that writes no `` at all builds no -reference either, so deferring the check to named lookup would let both run to -completion on an assembly nobody validated. - -**An identical repetition is not a disagreement.** One package's declarative -vocabulary is deliberately installed at more than one layer — the -repository-composition set is bootstrapped by an ordinary run and again inside a -workflow attachment, because either may be the only one — and the inner scope -descends from the outer, so both wrappers sit in one chain. A bootstrap that -finds its own statement already there does not append it again, so the component -is documented exactly once and the layering is not a failure. What makes two -contributions the same statement is the owning package, the asset, and the exact -set of components; differing in any of the three makes them two statements, and -two statements about one component refuse. +**One captured contribution is four values**: its owning package, its asset +identity, its exact documentation text, and its component-name set. Equality +covers all four. Set insertion order and JavaScript object identity do not +matter, so two bootstraps that build fresh objects and list the same names in +different orders have contributed the same value. + +**A repetition of the same value adds nothing and succeeds.** One package's +declarative vocabulary is deliberately entered at more than one layer — the +repository-composition set by an ordinary run's bootstrap and again inside a +workflow attachment, because either may be the only one, and a nested run or +evaluation host the same way — and the inner scope descends from the outer, so +both wrappers sit in one chain. A bootstrap that finds its own value already +there does not append it again: one bootstrap and two identical bootstraps +produce exactly the same captured documentation, and the repeated bootstrap +keeps both its registrations and one documentation value. + +**Two non-identical contributions overlapping one owning-package and +component-name pair refuse before root execution**, whichever middleware or +bootstrap order supplied them — no order chooses a winner, and there is no +last-write-wins. A changed asset, changed text, or a different-but-overlapping +component set each refuse. Comparing the asset alone would coalesce two +bootstraps that genuinely disagree and silently keep whichever ran first. + +**A different owner is unequal but is not by itself a conflict.** Documentation +joins by component name *and* origin, so two packages may each document a +same-spelled component and neither answers for the other's. Disjoint component +sets from one owner coexist for the same reason. Collection is where a genuine +conflict is caught, because it is the only boundary every execution passes +through: a document that writes bare `` builds no documentation index, +and one that writes no `` at all builds no reference either, so deferring +the check to named lookup would let both run to completion on an assembly nobody +validated. + +Bootstrap and execution scopes are isolated, and collection is already +execution-scoped — so "refuse only within one scope" is not a separate duplicate +policy; it only describes which contribution list either policy would inspect. 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; @@ -10958,7 +10976,11 @@ component that observes one at an authored site. | 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 | -| SYN25l | A repeated bootstrap is idempotent | An identical repeated bootstrap — one package entered twice, naming the same components from the same asset — leaves a named ``, a bare `` and a document containing no `` at all each working, and the collected snapshot holds that contribution exactly once; two bootstraps naming one component from *different* assets still refuse, which is what keeps this from passing vacuously | +| SYN25l.1 | A value-identical repetition adds nothing | One package entered twice — fresh contribution objects, the same four values, the names listed in the opposite order — leaves a named ``, a bare `` and a document containing no `` each working, and one bootstrap captures exactly what two capture | +| SYN25l.2 | A changed value refuses, in either order | For one owner and an overlapping component, a changed asset, changed text and a different-but-overlapping component set each refuse before root execution, whichever order the bootstraps supplied them | +| SYN25l.3 | Distinct owner and disjoint set are controls | Two owners documenting a same-spelled component do not conflict and neither takes the other's prose; one owner accounting for disjoint sets from two files does not conflict either | +| SYN25l.4 | Overlapping executions are isolated | An assembly carrying a genuine conflict refuses while a second execution live at the same time renders its own documentation, unaffected | +| SYN25l.5 | Layered trusted bootstraps keep both halves | An inner trusted layer entering the same package bootstrap as the outer keeps every layer's registrations resolvable *and* the package's documentation, rendered once from one coalesced value | | 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 | From 28e2030ce805e30b1873da0c22c05d32ac28e073 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 05:09:13 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Classify=20duplicate?= =?UTF-8?q?=20documentation=20at=20collection,=20not=20in=20the=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Documentation` is public. A package may compose `around(...)` itself and hand back two value-identical contributions without going through `contributeDocumentation()`, and that assembly is exactly as valid — but classification lived in the helper, so collection saw two contributions for one owner and component and refused. Correctness depended on which spelling a package happened to choose. Collection now owns it: snapshot the composed chain by value first, fold every completely identical contribution to one, then classify what is left. The helper appends and nothing more, so there is one rule in one place rather than two that can drift. Non-identical contributions overlapping one owner and component still refuse before root execution in either order, and a different owner or a disjoint set is still no conflict. SYN25l.6 installs two direct middleware wrappers that never call the helper — fresh objects, the same four values, name sets in opposite orders — and requires the named form, the bare form and a no-`` root to work with one contribution captured, then requires the three disagreements reached that way to refuse in both orders. Moving classification back into the helper fails that row and passes every other one in the tier. --- architecture.md | 16 +++- packages/core/src/documentation-api.ts | 82 ++++++++++++-------- packages/core/tests/syntax-component.test.ts | 80 ++++++++++++++++++- specs/executable-mdx-spec.md | 13 +++- 4 files changed, 152 insertions(+), 39 deletions(-) diff --git a/architecture.md b/architecture.md index 0d3e8891..1fdd2ee4 100644 --- a/architecture.md +++ b/architecture.md @@ -3731,10 +3731,18 @@ declarative vocabulary is deliberately entered at more than one layer, the repository-composition set by an ordinary run's bootstrap and again inside a workflow attachment because either may be the only one, and a nested run or evaluation host the same way. The inner scope descends from the outer, so both -wrappers sit in one chain; the second finds its own value already there and does -not append it, keeping its registrations and one documentation value. Comparing -the asset alone would instead coalesce two bootstraps that genuinely disagree -and silently keep whichever ran first. +wrappers sit in one chain, and collection folds them to one — keeping every +layer's registrations and one documentation value. Comparing the asset alone +would instead coalesce two bootstraps that genuinely disagree and silently keep +whichever ran first. + +**Collection classifies duplicates, not the helper a bootstrap happened to +use.** The middleware namespace is public, so a package may compose it directly +and hand back two value-identical contributions rather than going through the +convenience helper; that assembly is exactly as valid. Canonical collection +therefore snapshots the composed chain by value, folds every completely +identical contribution, and only then classifies what is left — so correctness +does not depend on which spelling a package chose. A different owner is unequal but is not by itself a conflict, because documentation joins by component name *and* origin — two packages may each diff --git a/packages/core/src/documentation-api.ts b/packages/core/src/documentation-api.ts index 0e6d4733..49fa6235 100644 --- a/packages/core/src/documentation-api.ts +++ b/packages/core/src/documentation-api.ts @@ -100,29 +100,20 @@ export const Documentation: Api = createApi( * and through the reader the collector supplies: a bootstrap installed in one * execution's scope reads that execution's assets. * - * **A contribution of the same value already in the chain is not appended - * twice.** One package's declarative vocabulary is deliberately entered at more - * than one layer — the repository-composition set by an ordinary run's bootstrap - * *and* again inside a workflow attachment, because either may be the only one; - * a nested run or evaluation host the same way — and the inner scope descends - * from the outer, so both wrappers are in one chain. Appending the second would - * refuse at collection, which turns valid layering into a failure while there is - * no ambiguity to resolve: a repetition of the same value says exactly what the - * first one said, and there is no winner to pick. So it coalesces, and the - * repeated bootstrap keeps both its registrations and one documentation value. - * - * Equality is by value over all four of {@link identical}'s inputs. Anything - * short of that — matching on the asset alone, say — would coalesce two - * bootstraps that genuinely disagree, and silently keep whichever ran first. + * It appends, and nothing more. Whether a repeated bootstrap is one statement + * made twice or two bootstraps disagreeing is {@link capturedDocumentation}'s + * question, because this helper is a convenience and the Api it wraps is + * public: a package may compose `Documentation.around(...)` directly, and + * correctness cannot depend on which of the two it chose. Deduplicating here as + * well would put the rule in two places, and the copy that ran second would be + * the one nobody tested. */ export function* contributeDocumentation( contribute: (read: DocumentationReader) => Operation, ): Operation { yield* Documentation.around({ *contributions([read], next): Operation { - const enclosing = yield* next(read); - const mine = yield* contribute(read); - return enclosing.some((one) => identical(one, mine)) ? enclosing : [...enclosing, mine]; + return [...(yield* next(read)), yield* contribute(read)]; }, }); } @@ -164,18 +155,29 @@ function sameNames(one: ReadonlySet, other: ReadonlySet): boolea * reason the installation boundary snapshots anything — the objects belong to * whoever built them, and their `Set`s and strings can move afterwards. * - * Two contributions that *disagree* about 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. - * - * Only a disagreement reaches this far: a repetition of the same value never - * arrives, because `contributeDocumentation()` recognizes its own statement in - * the chain and does not append it twice. What is left overlaps on one owning - * package and one component name while differing in asset, text or the set of - * components accounted for. Two *different owners* documenting a same-spelled - * component is not a conflict — documentation joins by name and origin — and - * neither is one owner accounting for disjoint sets from two files. + * **This boundary decides what a duplicate is**, not the helper that most + * bootstraps happen to use. `Documentation` is public: a package may compose + * `Documentation.around(...)` itself and hand back two value-identical + * contributions directly, and that assembly is exactly as valid as one built + * with {@link contributeDocumentation}. Classifying in the helper instead would + * make correctness depend on which spelling a package chose. + * + * So: snapshot first, then fold, then classify. A contribution is four + * values — owning package, asset identity, exact documentation text, and the set + * of components it accounts for. Every completely identical contribution folds + * to one, because a repetition says exactly what the first said and there is no + * winner to pick; that is what lets one package's declarative vocabulary be + * entered at more than one layer, as an ordinary run's bootstrap and a workflow + * attachment both do, without losing either layer's registrations. + * + * What is left after folding are contributions that *disagree*: they overlap on + * one owning package and one component name while differing in asset, text or + * the set accounted for. Those refuse, 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 bootstrapped packages in. Two *different owners* + * documenting a same-spelled component is not a conflict, because documentation + * joins by name and origin, and neither is one owner accounting for disjoint + * sets from two files. * * Collection is where a disagreement is caught, because it is the only boundary * every execution passes through. Deferring it to the named form's index would @@ -185,13 +187,29 @@ function sameNames(one: ReadonlySet, other: ReadonlySet): boolea export function* capturedDocumentation( read: DocumentationReader = packagedAssetReader, ): Operation { - const contributed = yield* Documentation.operations.contributions(read); + // Snapshotted *before* anything is compared, so what is classified is what + // will be kept. A contribution is a caller's object until this line — its + // strings and its `Set` can move afterwards — and comparing the live objects + // would decide identity against values the snapshot might not preserve. + const captured = snapshotContributions(yield* Documentation.operations.contributions(read)); + + // Every completely identical contribution folds to one, whichever way it + // reached the chain: the convenience helper and a direct + // `Documentation.around(...)` are the same to this boundary, which is the + // point of deciding here rather than in the helper. + const folded: DocumentationContribution[] = []; + for (const one of captured) { + if (!folded.some((kept) => identical(kept, one))) { + folded.push(one); + } + } + // Keyed by owning package *and* component name, because that pair is what // documentation joins on. Two packages may document same-spelled components, // and one package may account for disjoint sets from two files; neither is a // question anyone has to answer, so neither is a conflict. const seen = new Map(); - for (const one of contributed) { + for (const one of folded) { for (const name of one.supplies) { const owner = one.source.owner; const first = seen.get(`${owner} ${name}`); @@ -206,7 +224,7 @@ export function* capturedDocumentation( seen.set(`${owner} ${name}`, one); } } - return snapshotContributions(contributed); + return Object.freeze(folded); } /** diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 8ba3fe50..b53ac222 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -52,7 +52,11 @@ import { retainedSource } from "../src/root-source.ts"; import { renderSyntaxMarkdown } from "../src/syntax-markdown.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 { + capturedDocumentation, + contributeDocumentation, + Documentation, +} 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"; @@ -852,6 +856,80 @@ describe("Tier SYN — the named form", () => { expect(yield* conflicted).toContain("contributes documentation for Marker twice"); }); + it("SYN25l.6: duplicate classification belongs to collection, not to the helper", function* () { + // `Documentation` is public, so a package may compose `around(...)` itself + // and hand back two value-identical contributions directly. That assembly + // is exactly as valid as one built with `contributeDocumentation()`, and + // this proves collection says so — nothing below calls the helper. + // + // A rule that lived in the helper would pass every other row in this tier + // and fail here, which is the point of the row. + const { installation: marker } = stating(symbolsOf("Marker", "Other")); + const directly = (vary: Parameters[0] = {}): Operation => + Documentation.around({ + *contributions([read], next): Operation { + // A fresh object on every ask, so nothing here can pass by identity. + return [ + ...(yield* next(read)), + { + source: { + owner: vary.owner ?? "@executablemd/test", + asset: vary.asset ?? "packages/test/src/components.md", + text: vary.text ?? PAIR_PROSE, + }, + supplies: new Set(vary.supplies ?? ["Marker", "Other"]), + }, + ]; + }, + }); + + const twice = (source: string): Operation => + scoped(function* () { + yield* directly(); + // The same four values, with the names listed the other way round. + yield* directly({ supplies: ["Other", "Marker"] }); + return yield* run(source, [marker]); + }); + + expect(String(yield* twice('\n'))).toContain("MARKER PROSE."); + expect(String(yield* twice("\n"))).toContain("### ``"); + expect(String(yield* twice("nothing here\n"))).toContain("nothing here"); + + // And canonical capture holds one contribution, not two. + const captured = yield* scoped(function* () { + yield* directly(); + yield* directly({ supplies: ["Other", "Marker"] }); + return yield* capturedDocumentation(); + }); + expect( + captured.filter((one) => one.source.asset === "packages/test/src/components.md"), + ).toHaveLength(1); + + // The same three disagreements, reached the same direct way, still refuse + // before the root — in either order, and with no `` to discover it. + const conflicts: [string, Parameters[0]][] = [ + ["a changed asset", { asset: "packages/other/src/components.md" }], + ["changed text", { text: `${PAIR_PROSE}\nSUBSTITUTED.\n` }], + ["an overlapping but different set", { supplies: ["Marker", "Extra"] }], + ]; + for (const [what, changed] of conflicts) { + for (const reversed of [false, true]) { + const refused = yield* refusal( + scoped(function* () { + yield* directly(reversed ? changed : {}); + yield* directly(reversed ? {} : changed); + return yield* run("nothing here\n", [marker]); + }), + ); + expect([ + what, + reversed, + refused.includes("contributes documentation for Marker twice"), + ]).toEqual([what, reversed, true]); + } + } + }); + it("SYN25l.5: layered trusted bootstraps keep their registrations and documentation", function* () { // The shape a nested run and an `` host both take: an outer // trusted layer enters a package's bootstrap, an inner one enters the same diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 2ed504ed..4ddeb898 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2769,11 +2769,19 @@ declarative vocabulary is deliberately entered at more than one layer — the repository-composition set by an ordinary run's bootstrap and again inside a workflow attachment, because either may be the only one, and a nested run or evaluation host the same way — and the inner scope descends from the outer, so -both wrappers sit in one chain. A bootstrap that finds its own value already -there does not append it again: one bootstrap and two identical bootstraps +both wrappers sit in one chain. One bootstrap and two identical bootstraps produce exactly the same captured documentation, and the repeated bootstrap keeps both its registrations and one documentation value. +**Collection classifies duplicates, and a convenience helper does not.** The +middleware namespace is public: a package may compose it directly and hand back +two value-identical contributions rather than going through the helper most +bootstraps use, and that assembly is exactly as valid. So canonical collection +snapshots the composed chain by value first, folds every completely identical +contribution to one, and only then classifies what is left. A helper may +suppress a duplicate of its own as an optimization, but only where bypassing it +produces exactly the same accepted snapshot and the same conflict behavior. + **Two non-identical contributions overlapping one owning-package and component-name pair refuse before root execution**, whichever middleware or bootstrap order supplied them — no order chooses a winner, and there is no @@ -10981,6 +10989,7 @@ component that observes one at an authored site. | SYN25l.3 | Distinct owner and disjoint set are controls | Two owners documenting a same-spelled component do not conflict and neither takes the other's prose; one owner accounting for disjoint sets from two files does not conflict either | | SYN25l.4 | Overlapping executions are isolated | An assembly carrying a genuine conflict refuses while a second execution live at the same time renders its own documentation, unaffected | | SYN25l.5 | Layered trusted bootstraps keep both halves | An inner trusted layer entering the same package bootstrap as the outer keeps every layer's registrations resolvable *and* the package's documentation, rendered once from one coalesced value | +| SYN25l.6 | Collection owns duplicate classification | Two direct middleware wrappers that never call the convenience helper, contributing fresh objects with the same four values and differently ordered name sets, leave the named form, the bare form and a no-`` root each working with one contribution captured; the same three disagreements reached that way still refuse before the root in either order | | 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 |