Skip to content

Commit 2e1ba28

Browse files
committed
🪟 Derive a narrowed observation from the enclosing one (#758)
The narrowing seam required an evaluator to hold the raw documentation contributions and hand them back to build a narrowed observation. That list is execution-private for a reason, and rebuilding an index from it is how two indexes drift apart. `CatalogObservation` now derives its own: observation.narrow(admitted) What comes back reports the admitted vocabulary from `observe()` and keeps *this* observation's authoring catalog and documentation index for `document()`. #713 needs the admitted catalog and nothing else. Proved by breaking it: a narrowed observation that drops the enclosing reference fails SYN25e and nothing else. Contributions are snapshotted field by field at the boundary — owner, asset and text copied, the name set materialized into one this module owns. A contribution is a caller's object: its array can be reordered, its source replaced, its `Set` added to after capture, and an iterable can answer differently the second time it is walked. SYN25f mutates all of those after capture and proves the observed documentation and coverage are the captured values. SYN46 now proves cancellation *reached* the named documentation work rather than arriving before it: the observation records entry, then teardown, in that order, and a cancellation that never got inside would leave the first marker absent. It also states the mechanism correctly — a durable operation records its event on completion, so a cancelled one commits nothing at all rather than committing a failure. SX17 compares the complete rendered output of `xmd syntax Prompt` and `<Syntax names={["Prompt"]} />` rather than phrases from it. Substring agreement would pass just as happily if one surface kept the heading and silently dropped the documentation. Prose reconciled with what the code enforces: a first-party component with no section refuses the index, and the no-documentation sentence is for a custom component. `documentation-index.ts` and `architecture.md` said the opposite.
1 parent 71dbe97 commit 2e1ba28

5 files changed

Lines changed: 237 additions & 37 deletions

File tree

‎architecture.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3716,10 +3716,13 @@ distribution is produced. One validated index serves `<Syntax names={…}>`, `xm
37163716
#678's release reference, so three surfaces cannot describe one component three
37173717
ways. It joins by name *and* owning package: a repository `Elicit.md` has a
37183718
repository origin, which names no package, so the built-in's prose is never
3719-
attached to it. A heading naming something the package does not supply, one
3720-
appearing twice, and one that is not a component name each refuse the whole
3721-
index rather than producing a partial one; a component with no section is
3722-
ordinary and renders the sentence saying so.
3719+
attached to it. A first-party component with no
3720+
section, a heading naming something the package does not supply, one appearing
3721+
twice, and one that is not a component name each refuse the whole index rather
3722+
than producing a partial one — a reference with a hole in it cannot be told from
3723+
one whose components have nothing to say. The no-documentation sentence is for a
3724+
**custom** component instead, which no package governs and which takes its prose
3725+
from its own document's body when it has any.
37233726

37243727
**It says so in the catalog.** A protected component reports its own origin
37253728
kind, `protected`, rather than borrowing `registered` with `reserved: true`. The

‎packages/cli/tests/syntax-cli.test.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -500,17 +500,23 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources
500500
// profile's contributions while a document's own named form fell back to a
501501
// core-only index, so one product answered the same question two ways.
502502
const command = yield* runCli(["syntax", "Prompt", "--include", "."], { cwd: "." }).expect();
503-
expect(command.stdout).toContain("### `<Prompt>`");
504-
expect(command.stdout).toContain("Sends a prompt and renders the reply");
505-
506503
const document = yield* runCli(
507504
["run", "-e", '<Syntax names={["Prompt"]} />', "--include", "."],
508505
{ cwd: "." },
509506
).expect();
510-
expect(document.stdout).toContain("Sends a prompt and renders the reply");
511-
expect(document.stdout).not.toContain(
512-
"No long-form documentation is available for this component.",
513-
);
507+
508+
// The whole rendered result, not a phrase from it. Both surfaces render the
509+
// same text through the same renderer; they differ only in the trailing
510+
// newline a rendered document ends with, which is the presentation boundary
511+
// rather than the answer. Comparing substrings would pass just as happily
512+
// if one surface silently dropped the documentation and kept the heading.
513+
expect(document.stdout.trimEnd()).toBe(command.stdout.trimEnd());
514+
515+
// And it is a real answer rather than two matching empties.
516+
expect(command.stdout).toContain("### `<Prompt>`");
517+
expect(command.stdout).toContain("Sends a prompt and renders the reply");
518+
expect(command.stdout).toContain("**Available in this evaluation:** yes");
519+
expect(command.stdout.length).toBeGreaterThan(400);
514520
});
515521

516522
it("SX10: writes markdown by default and version-2 JSON with --json", function* () {

‎packages/core/src/documentation-index.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,23 @@
2323
* component whose section they are in, so a component's own documentation can
2424
* have structure without ending its section.
2525
*
26-
* Three things refuse the whole index rather than producing a partial one:
26+
* Four things refuse the whole index rather than producing a partial one:
2727
*
28+
* - a **missing** section — a component the package supplies and does not
29+
* document. A reader cannot tell "nobody has written this yet" from "this
30+
* component has nothing to say", so a first-party package with a hole in its
31+
* reference is not a valid build;
2832
* - a **duplicate** heading, in one file or across two, because then a component
2933
* has two documentations and nothing says which is current;
3034
* - an **unknown** heading, because it is documentation for something this
3135
* boundary does not register — a rename that updated one side, usually;
3236
* - a heading that is **not a component name at all**, which is a file that has
3337
* drifted from this format into ordinary prose.
3438
*
35-
* A component with *no* section is not a failure. It renders the sentence
36-
* `<Syntax>` states for one, and stays usable while its documentation is still
37-
* being written.
39+
* The no-documentation sentence `<Syntax>` renders is therefore *not* for a
40+
* first-party component. It is for a **custom** component — a repository file, a
41+
* bundle member, a host's declared Markdown — which no package governs and which
42+
* takes its documentation from its own document's body when it has any.
3843
*
3944
* ## The join is name *and* origin
4045
*

‎packages/core/src/syntax-observation.ts‎

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ export interface CatalogObservation {
7070
* author needs or imply an authority they do not have.
7171
*/
7272
document(names: readonly string[]): Operation<string>;
73+
/**
74+
* The observation for a subtree that may execute less than this site.
75+
*
76+
* The narrowing seam, and it belongs here rather than in the evaluator
77+
* because everything it needs is already here. A canonical evaluation
78+
* boundary that has admitted a vocabulary hands it over; what comes back
79+
* reports that vocabulary from `observe()` and keeps *this* observation's
80+
* authoring catalog and documentation index for `document()`.
81+
*
82+
* Deriving it any other way would mean the evaluator recovering the raw
83+
* contributions and rebuilding an index — which is both a hole (that list is
84+
* execution-private for a reason) and a way for the two indexes to drift.
85+
* Narrowing what may run is not narrowing what may be read about, and the
86+
* observation is the thing that already knows both.
87+
*/
88+
narrow(executable: SyntaxCatalog): CatalogObservation;
7389
}
7490

7591
/**
@@ -121,17 +137,48 @@ export function rootCatalogObservation(
121137
function* current(): Operation<SyntaxCatalog> {
122138
return contribution === undefined ? yield* derived(inputs) : yield* contribution();
123139
}
140+
// Snapshotted once, here, so the contributions an observation reads are the
141+
// ones the installation boundary captured rather than whatever the caller's
142+
// objects hold by the time a document asks.
143+
const captured = snapshotContributions(documentation);
144+
return observing(current, current, captured);
145+
}
146+
147+
/**
148+
* One observation over an authoring catalog and an executable one.
149+
*
150+
* `reference` is what named lookup reads and `executable` is what may run. At a
151+
* root they are the same operation; a narrowed observation keeps the reference
152+
* and replaces the executable, which is the whole of the seam.
153+
*/
154+
function observing(
155+
reference: () => Operation<SyntaxCatalog>,
156+
executable: () => Operation<SyntaxCatalog>,
157+
documentation: readonly DocumentationContribution[],
158+
): CatalogObservation {
124159
return {
125160
*observe(): Operation<string> {
126-
return renderSyntaxMarkdown(yield* current());
161+
return renderSyntaxMarkdown(yield* executable());
127162
},
128163
*document(names: readonly string[]): Operation<string> {
129-
// At the root the two inputs are one catalog: nothing has narrowed what
130-
// may execute, so what an author may read about and what they may run are
131-
// the same set, and every selected entry is available.
132-
const catalog = yield* current();
164+
const authoring = yield* reference();
165+
const runnable = yield* executable();
133166
const index = yield* documentationIndexFor(documentation);
134-
return renderSelectedDocumentation(select(catalog, catalog, names, index));
167+
return renderSelectedDocumentation(select(authoring, runnable, names, index));
168+
},
169+
narrow(admitted: SyntaxCatalog): CatalogObservation {
170+
// The enclosing reference and the enclosing index, unchanged. Only what
171+
// may execute is replaced, so a nested author keeps the documentation
172+
// they had and every entry reports its availability against the
173+
// admission.
174+
// deno-lint-ignore require-yield
175+
return observing(
176+
reference,
177+
function* () {
178+
return admitted;
179+
},
180+
documentation,
181+
);
135182
},
136183
};
137184
}
@@ -241,6 +288,33 @@ function* derived(inputs: CapturedCatalogInputs): Operation<SyntaxCatalog> {
241288
* adds nothing: the catalog handed here is the admission's, so an entry that is
242289
* not in the admission cannot be in the observation.
243290
*/
291+
/**
292+
* A defensive copy of what a caller handed the installation boundary.
293+
*
294+
* Field by field, and the name set materialized into one this module owns. A
295+
* contribution is a caller's object: the array can be reordered, the source
296+
* replaced, the `Set` added to after capture, and an iterable can answer
297+
* differently the second time it is walked. Retaining any of those would make
298+
* what a document is told about the product depend on what its host did
299+
* afterwards.
300+
*/
301+
export function snapshotContributions(
302+
contributions: readonly DocumentationContribution[],
303+
): readonly DocumentationContribution[] {
304+
return Object.freeze(
305+
[...contributions].map((one) =>
306+
Object.freeze({
307+
source: Object.freeze({
308+
owner: String(one.source.owner),
309+
asset: String(one.source.asset),
310+
text: String(one.source.text),
311+
}),
312+
supplies: new Set([...one.supplies].map((name) => String(name))),
313+
}),
314+
),
315+
);
316+
}
317+
244318
export function fixedCatalogObservation(
245319
catalog: SyntaxCatalog,
246320
/**
@@ -268,15 +342,16 @@ export function fixedCatalogObservation(
268342
*/
269343
documentation: readonly DocumentationContribution[] = [],
270344
): CatalogObservation {
271-
const rendered = renderSyntaxMarkdown(catalog);
272-
return {
345+
const captured = snapshotContributions(documentation);
346+
return observing(
273347
// deno-lint-ignore require-yield
274-
*observe(): Operation<string> {
275-
return rendered;
348+
function* () {
349+
return reference;
276350
},
277-
*document(names: readonly string[]): Operation<string> {
278-
const index = yield* documentationIndexFor(documentation);
279-
return renderSelectedDocumentation(select(reference, catalog, names, index));
351+
// deno-lint-ignore require-yield
352+
function* () {
353+
return catalog;
280354
},
281-
};
355+
captured,
356+
);
282357
}

‎packages/core/tests/syntax-component.test.ts‎

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ import { selectComponent } from "../src/components/select.ts";
4949
import { installedBundle } from "../src/components/bundle.ts";
5050
import { retainedSource } from "../src/root-source.ts";
5151
import { renderSyntaxMarkdown } from "../src/syntax-markdown.ts";
52-
import { fixedCatalogObservation } from "../src/syntax-observation.ts";
52+
import { fixedCatalogObservation, rootCatalogObservation } from "../src/syntax-observation.ts";
53+
import type { CatalogObservation } from "../src/syntax-observation.ts";
54+
import type { DocumentationContribution } from "../src/component-documentation.ts";
5355
import { SYNTAX_COMPONENT } from "../src/components/Syntax.ts";
5456
import type { ImportedDefinition } from "../src/components/import-authority.ts";
5557
import type { ComponentOrigin, FunctionComponent, SyntaxCatalog } from "../mod.ts";
@@ -187,6 +189,48 @@ function* tampered(
187189
return partial;
188190
}
189191

192+
/** A catalog holding one component entry of exactly this identity. */
193+
function catalogNamed(name: string, origin: NamedOrigin): SyntaxCatalog {
194+
return {
195+
version: 2,
196+
categories: [
197+
{ kind: "structural", entries: [] },
198+
{
199+
kind: "built-in",
200+
entries: [
201+
{
202+
kind: "component" as const,
203+
name,
204+
origin,
205+
sourceKind: "registered" as const,
206+
inspectability: "complete" as const,
207+
forms: ["self-closing" as const],
208+
props: { type: "object", properties: {}, additionalProperties: false },
209+
captures: [],
210+
returnMode: "text" as const,
211+
returns: { type: "string" },
212+
},
213+
],
214+
},
215+
{ kind: "user-provided", entries: [] },
216+
],
217+
};
218+
}
219+
220+
/**
221+
* The observation an ordinary root carries.
222+
*
223+
* Built the way an execution builds it — from captured selection inputs, with
224+
* no host contribution — so a case about narrowing is about the object the
225+
* product actually hands to expansion.
226+
*/
227+
function rootObservation(): CatalogObservation {
228+
return rootCatalogObservation(
229+
{ includes: [], registry: new Map(), components: [], declarations: [] },
230+
undefined,
231+
);
232+
}
233+
190234
/** A working directory of this case's own, torn down on the way out. */
191235
function useWorkingDirectory<T>(body: (dir: string) => Operation<T>): Operation<T> {
192236
return scoped(function* () {
@@ -372,8 +416,13 @@ describe("Tier SYN — the named form", () => {
372416
// could be written in.
373417
const suspending: ExecutionInstallation = {
374418
*catalog(): Operation<SyntaxCatalog> {
419+
// Entered *inside* the named documentation operation: the component has
420+
// claimed its occurrence and opened its durable operation by the time
421+
// this runs, so a cancellation that arrives now is one that landed in
422+
// the work rather than before it.
423+
torn.push("entered");
375424
yield* ensure(() => {
376-
torn.push("observation");
425+
torn.push("torn down");
377426
});
378427
yield* suspend();
379428
return catalogOf("Unreachable");
@@ -389,12 +438,16 @@ describe("Tier SYN — the named form", () => {
389438
yield* task.halt();
390439
});
391440

392-
// Teardown ran, so the observation's own cleanup completed rather than
393-
// being abandoned mid-flight.
394-
expect(torn).toEqual(["observation"]);
395-
// And nothing successful was retained: a continuation has no catalog to
396-
// restore, which is the honest state for work that never finished.
397-
expect(retained(yield* stream.readAll())).toHaveLength(0);
441+
const events = yield* stream.readAll();
442+
// Reached the work, then tore it down — in that order. Cancelling before
443+
// the observation was entered would leave `entered` absent, which is the
444+
// vacuous pass this ordering rules out.
445+
expect(torn).toEqual(["entered", "torn down"]);
446+
// Nothing was committed at all: a durable operation records its event when
447+
// it completes, and this one never did. So there is no record for a
448+
// continuation to restore, successful or otherwise.
449+
expect(observations(events)).toHaveLength(0);
450+
expect(retained(events)).toHaveLength(0);
398451
});
399452

400453
it("SYN39: retains the named text, and a continuation restores it whole", function* () {
@@ -1214,6 +1267,64 @@ describe("Tier SYN — observation is never authority", () => {
12141267
).toContain("**Available in this evaluation:** yes");
12151268
});
12161269

1270+
it("SYN25e: a narrowed observation is derived from the enclosing one", function* () {
1271+
// The seam as an evaluator actually meets it: it holds the enclosing
1272+
// observation and an admitted catalog, and nothing else. No raw
1273+
// contribution list, no second index — which is the point, because that
1274+
// list is execution-private and rebuilding an index from it is how two
1275+
// indexes drift apart.
1276+
const enclosing = rootObservation();
1277+
const admitted = catalogOf("Admitted");
1278+
const narrowed = enclosing.narrow(admitted);
1279+
1280+
// What may run is the admission.
1281+
const executable = yield* narrowed.observe();
1282+
expect(executable).toContain("### `<Admitted>`");
1283+
expect(executable).not.toContain("### `<Elicit>`");
1284+
1285+
// What may be read about is still the enclosing site's, with the enclosing
1286+
// index behind it — so a real component's real documentation survives.
1287+
const documented = yield* narrowed.document(["Elicit"]);
1288+
expect(documented).toContain("### `<Elicit>`");
1289+
expect(documented).toContain("Asks a person a structured question");
1290+
expect(documented).toContain("**Available in this evaluation:** no");
1291+
1292+
// And the enclosing observation is unchanged by having been narrowed.
1293+
expect(yield* enclosing.observe()).toContain("### `<Elicit>`");
1294+
expect(yield* enclosing.document(["Elicit"])).toContain(
1295+
"**Available in this evaluation:** yes",
1296+
);
1297+
});
1298+
1299+
it("SYN25f: contributions are captured, not held by reference", function* () {
1300+
const supplies = new Set(["Alpha"]);
1301+
const source = {
1302+
owner: "@executablemd/mutable",
1303+
asset: "packages/mutable/src/components.md",
1304+
text: "## Alpha\n\nThe captured documentation.\n",
1305+
};
1306+
const contribution = { source, supplies };
1307+
const observation = fixedCatalogObservation(
1308+
catalogNamed("Alpha", {
1309+
kind: "registered",
1310+
origin: "@executablemd/mutable",
1311+
reserved: false,
1312+
}),
1313+
undefined,
1314+
[contribution],
1315+
);
1316+
1317+
// Everything a caller still holds, changed after capture.
1318+
source.text = "## Alpha\n\nSUBSTITUTED AFTER CAPTURE.\n";
1319+
source.owner = "@executablemd/other";
1320+
supplies.add("Beta");
1321+
supplies.delete("Alpha");
1322+
1323+
const rendered = yield* observation.document(["Alpha"]);
1324+
expect(rendered).toContain("The captured documentation.");
1325+
expect(rendered).not.toContain("SUBSTITUTED AFTER CAPTURE");
1326+
});
1327+
12171328
it("SYN25: an execution that carries no observation refuses rather than inventing one", function* () {
12181329
// `execute()` driven directly still carries one, so the case that has none
12191330
// is an expansion driven outside an execution — which is what a component

0 commit comments

Comments
 (0)