diff --git a/architecture.md b/architecture.md
index d8a775e2..1fdd2ee4 100644
--- a/architecture.md
+++ b/architecture.md
@@ -3706,9 +3706,55 @@ 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 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.
+
+**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, 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
+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
+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 +4035,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 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/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..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);
@@ -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..49fa6235 100644
--- a/packages/core/src/documentation-api.ts
+++ b/packages/core/src/documentation-api.ts
@@ -99,6 +99,14 @@ 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.
+ *
+ * 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,
@@ -110,6 +118,34 @@ export function* contributeDocumentation(
});
}
+/**
+ * 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.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.
*
@@ -119,30 +155,94 @@ export function* contributeDocumentation(
* 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.
+ * **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
+ * let a document that writes bare ``, or no `` at all, run to
+ * completion on an assembly nobody validated.
*/
export function* capturedDocumentation(
read: DocumentationReader = packagedAssetReader,
): Operation {
- const contributed = yield* Documentation.operations.contributions(read);
- const seen = new Map();
- for (const one of contributed) {
+ // 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 folded) {
for (const name of one.supplies) {
const owner = one.source.owner;
- const key = `${owner} ${name}`;
- const first = seen.get(key);
- if (first !== undefined && first !== one.source.asset) {
+ const first = seen.get(`${owner} ${name}`);
+ 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.",
+ `${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);
+ return Object.freeze(folded);
+}
+
+/**
+ * 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 602481f2..b53ac222 100644
--- a/packages/core/tests/syntax-component.test.ts
+++ b/packages/core/tests/syntax-component.test.ts
@@ -45,20 +45,25 @@ 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";
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";
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 +73,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 +99,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 };
@@ -112,20 +117,67 @@ function stating(catalog: 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,
@@ -153,22 +205,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 +263,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 +328,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 +347,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 +400,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 +536,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 +582,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 +595,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 +611,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 +647,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 +699,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"],
@@ -656,15 +708,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,11 +724,247 @@ describe("Tier SYN — the named form", () => {
expect(orders[0]).not.toBe(orders[1]);
});
+ 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.
+ //
+ // 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({ 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 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");
+
+ // 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();
+ return yield* run('\n', [marker]);
+ }),
+ );
+ expect(beside).toContain("MARKER PROSE.");
+ expect(beside).not.toContain("CONFLICTING");
+ 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
+ // 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* () {
// 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();
@@ -738,16 +1026,16 @@ 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
*symbols(): Operation {
calls.count += 1;
- return catalogOf(`Marker${calls.count}`);
+ return symbolsOf(`Marker${calls.count}`);
},
};
@@ -762,7 +1050,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(
@@ -789,7 +1077,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);
@@ -802,7 +1090,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)),
@@ -815,13 +1103,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 [
@@ -842,16 +1130,16 @@ 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(catalogOf("Marker"));
+ const { installation } = stating(symbolsOf("Marker"));
const output = String(
yield* run("\n\n", [installation], undefined, [dir]),
);
// The protected component answered, and the repository file did not.
expect(output).toContain("### ``");
- expect(output).not.toContain("a repository catalog");
+ 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");
@@ -860,7 +1148,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
@@ -953,7 +1241,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",
@@ -964,7 +1252,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);
@@ -977,7 +1265,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",
);
});
@@ -986,7 +1274,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",
@@ -1030,7 +1318,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(
{
@@ -1043,15 +1331,15 @@ describe("Tier SYN — what the chain may and may not do", () => {
);
},
};
- const { installation } = stating(catalogOf("Marker"));
- expect(String(yield* run("\n", [installation, observing]))).toContain("Marker");
+ const { installation } = stating(symbolsOf("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][] = [
[
@@ -1077,10 +1365,10 @@ 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.
+ // Refused before the body: no symbols were read for the replacement.
expect(calls.count).toBe(0);
}
});
@@ -1102,7 +1390,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 +1412,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();
});
@@ -1135,7 +1423,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);
},
@@ -1144,9 +1432,9 @@ 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",
+ "this host refuses the symbols",
);
expect(calls.count).toBe(0);
});
@@ -1154,25 +1442,25 @@ 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.
- const { installation } = stating(catalogOf("Marker"));
+ // 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]));
expect(output).toContain("### ``");
- expect(output).not.toContain("a planted catalog");
+ expect(output).not.toContain("planted symbols");
});
});
-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("### ``");
@@ -1181,15 +1469,15 @@ 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* () {
- const { installation } = stating(catalogOf("Marker"));
- const catalog = yield* scoped(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(
{
...retainedSource(ROOT_PATH, "\n"),
@@ -1200,12 +1488,12 @@ 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 ");
+ 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" });
@@ -1240,7 +1528,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,9 +1537,9 @@ 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");
+ throw new Error("expected the symbols to describe completely");
}
expect(entry.origin).toEqual({
kind: "workflow",
@@ -1261,7 +1549,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: [
@@ -1286,15 +1574,15 @@ 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* () {
- const source = ['', "policy sees {catalog}", ""].join("\n");
+ 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",
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 +1594,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");
}
@@ -1316,10 +1604,10 @@ 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(catalogOf("Before")).installation], first),
+ yield* run("\n", [stating(symbolsOf("Before")).installation], first),
);
expect(before).toContain("### ``");
@@ -1328,7 +1616,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)));
@@ -1338,7 +1626,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 +1638,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,
),
);
@@ -1363,7 +1651,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 = {
@@ -1387,7 +1675,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([]);
@@ -1395,10 +1683,10 @@ 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(catalogOf("Phantom"));
+ const { installation } = stating(symbolsOf("Phantom"));
const output = String(yield* run("\n", [installation]));
expect(output).toContain("### ``");
@@ -1410,8 +1698,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 +1723,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);
@@ -1462,39 +1750,39 @@ 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* () {
- const narrowed = catalogOf("Admitted");
+ 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* () {
- 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
+ // 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"]);
@@ -1506,7 +1794,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(
@@ -1515,7 +1803,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: [
@@ -1605,12 +1893,12 @@ 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.
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 +1929,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,
@@ -1664,7 +1952,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 e95f5523..4ddeb898 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,71 @@ 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.
+
+**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. 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
+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;
+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,11 +2856,11 @@ 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
-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 +9920,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
@@ -10911,7 +10962,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 +10974,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 +10984,12 @@ 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.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 |
+| 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 |