diff --git a/architecture.md b/architecture.md index b34f9d5a..cde8e711 100644 --- a/architecture.md +++ b/architecture.md @@ -1602,7 +1602,37 @@ Availability of File does not admit its paired write form. Describing a component neither imports it nor grants its effects. Bindings and structural constructs are engine-owned language syntax and require -no profile entry. A trusted host may add another pure component only by placing +no profile entry. Every built-in construct is available inside a generated +fragment with its ordinary semantics — `Content`, `Output`, `Return`, `Let`, +`Each`, `If`, `Else`, `Switch`, `Case`, `Loop`, `Break`, `PrintErrors`, +`Answers` and `Answer` — decided by the same source rules ordinary validation +and expansion read, so a construct means one thing whether a person or an Agent +wrote it. Preflight dispatches a reserved name to those rules before the +admitted table is consulted, which is why a construct is never reported as a +component the host withheld; a construct the generated root supplies no context +for, a stray branch, and a `` outside every `` fail with their +ordinary structural rule instead. + +Preflight walks every branch, every ``, every iteration body and every +nested region before the first effect, so a prohibited component in a path the +run never takes refuses the whole fragment with no read performed, while every +effectful component a construct reaches stays held to the selected authority. +What a value decides stays at runtime: a condition, a matcher, a computed `max` +and a failing read are evaluated where they always were. + +Binding ownership is stated per region. Component children do not escape. An +`` body is a fresh scope holding the enclosing bindings and the item, and +nothing it binds survives it, because that body may run no times at all — only +`` joins the enclosing scope. The alternatives of an `` and the +`` branches of a `` are checked from one incoming snapshot, so no +alternative supplies a binding to another; the union of what they can produce +becomes visible afterwards, which keeps reading a binding only one arm makes a +runtime failure rather than a preflight refusal. ``, `` and +`` bodies read and write the enclosing environment. Constructs keep their +exact spelling in the retained source, never join the retained named-component +list, and change no record version. + +A trusted host may add another pure component only by placing its exact definition and stable identity in the composition table captured before execution. It is not inferred to be pure from its name, documentation or absence from an effect table. Names and forms across composition and effect diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index f864df99..ca7fd503 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -1499,6 +1499,13 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // The agent, told the document's own vocabulary, writes something a // fragment may not contain. The narrower `` refuses it before // any effect rather than running part of it. + // + // An executable fence rather than a structural construct: a `` + // around an admitted read is ordinary generated composition now, so it + // no longer discriminates. The admitted read is written *first*, so a + // refusal that happened element-by-element would already have performed + // it — the empty recorder is what proves preflight decided the whole + // fragment. const run = yield* runDocument({ source: [ 'Write a program.', @@ -1506,7 +1513,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { "", "", ].join("\n"), - reply: `\n\n\n`, + reply: '\n\n```bash exec\nprintf ran\n```\n', evaluation: profile(files), }); diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 62091f91..1cb836d0 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -129,11 +129,32 @@ import { renderSegments } from "./render.ts"; import { scanSegments } from "./scanner.ts"; import { sourceDescription } from "./source-position.ts"; import { RESERVED_STRUCTURAL } from "./structural.ts"; +// The ordinary source rules, read rather than reimplemented: a construct means +// one thing whether a person wrote it or an Agent did. +import { + answersViolations, + answerViolations, + breakViolations, + eachCaptureBinding, + eachItemBinding, + eachViolations, + ifConditionViolation, + ifPropsViolation, + ifStructure, + isBlankText, + letBindingName, + letViolations, + loopViolations, + printErrorsViolations, + switchStructure, +} from "./structural-rules.ts"; +import type { StructuralViolation } from "./structural-rules.ts"; import { installFormSelections, invocationForm } from "./invocation-identity.ts"; import type { FormSelections, ProtectedBodies } from "./invocation-identity.ts"; import type { ComponentInvocation } from "./invocation-identity.ts"; import type { SyntaxReference } from "./syntax-reference.ts"; import type { + ComponentElement, FunctionComponentDefinition, Json, JsonObject, @@ -156,6 +177,7 @@ type Construct = | "content" | "form" | "construct" + | "structure" | "request"; /** @@ -180,6 +202,14 @@ const CONSTRUCT: Record = { "a generated fragment writes self-closing a component this host admitted only in its " + "paired form.", construct: "a generated fragment carries a construct this evaluator does not admit.", + // Distinct from `component`, because the two are different mistakes. A + // structural construct is language rather than authority: writing one badly, + // or writing one where the generated root supplies no context for it, is a + // source error the candidate can correct — not a statement that the host + // withheld something. + structure: + "a generated fragment writes a structural construct the language does not allow where it " + + "was written.", request: "a generated fragment asks for a request this host did not admit.", }; @@ -869,6 +899,28 @@ type RetainedAdmission = * and the method belongs to whatever object a caller passed; both are answers * about something other than the element (executable-mdx-spec §5.6). */ +/** + * The admission this invocation runs under, chosen by the form it was written + * as. + * + * The engine's own issuance decides, not the caller: a wrapper can mint an + * object carrying a `hasContent` method, and it cannot mint an issuance. An + * element whose form the host admitted for no entry under this name reaches no + * admission and is refused with the same diagnostic a mismatched invocation + * always produced. + */ +function plannedForm( + byForm: ReadonlyMap, + invocation: ComponentInvocation, +): Planned { + const written = invocationForm(invocation); + const planned = written === undefined ? undefined : byForm.get(written); + if (planned === undefined) { + throw new GeneratedXmdError(SHAPE); + } + return planned; +} + function holdForm(form: AuthoredForm, invocation: ComponentInvocation): void { // The engine's own account of the element, not the method on the object this // was handed. A wrapper can mint an object carrying that method; it cannot @@ -900,7 +952,18 @@ function holdForm(form: AuthoredForm, invocation: ComponentInvocation): void { * entry preflight selected rather than of what the component returned. */ class GeneratedImportAuthority implements ImportAuthority { - readonly #planned: Map; + /** + * Every admission, by the name and the authored form it was made for. + * + * Two keys rather than one, and no consumption. Preflight reads every + * alternative and every body once; the run enters one arm of an ``, and + * may enter one authored element many times or not at all — so a queue drawn + * down per import has neither the cardinality nor the order the run has. A + * name and a form select exactly one admission because the table says so: + * `admitted()` refuses one name holding two definitions, and refuses one name + * and form twice. + */ + readonly #planned: Map>; readonly #imports = new CanonicalImports(); /** * This fragment's own selection frames. @@ -913,7 +976,7 @@ class GeneratedImportAuthority implements ImportAuthority { /** The form authority under each admitted name's wrapper. */ readonly #dispatchers = new Map(); readonly #protectedBodies: ProtectedBodies | undefined; - readonly #invocations = new WeakMap(); + readonly #invocations = new WeakMap>(); /** * A generated fragment may invoke only what the host admitted for it, so @@ -926,14 +989,11 @@ class GeneratedImportAuthority implements ImportAuthority { } constructor(named: readonly Planned[], protectedBodies?: ProtectedBodies) { - const planned = new Map(); + const planned = new Map>(); for (const invocation of named) { - const queue = planned.get(invocation.name); - if (queue === undefined) { - planned.set(invocation.name, [invocation]); - continue; - } - queue.push(invocation); + const byForm = planned.get(invocation.name) ?? new Map(); + byForm.set(invocation.form, invocation); + planned.set(invocation.name, byForm); } this.#planned = planned; this.#protectedBodies = protectedBodies; @@ -941,15 +1001,19 @@ class GeneratedImportAuthority implements ImportAuthority { /** The answer canonical execution produces for this name. */ issue(name: string): ImportedDefinition { - // Imports happen once per element and in the order the walk read them, so - // the head of this name's queue is the entry preflight selected for the - // element being expanded. An import the plan does not account for is an - // element preflight never saw, and it is refused rather than resolved. - const planned = this.#planned.get(name)?.shift(); - if (planned === undefined) { + const byForm = this.#planned.get(name); + if (byForm === undefined) { throw new GeneratedXmdError(CONSTRUCT.component); } - const { entry, form } = planned; + // Every entry under one name shares one definition — the table refuses two + // — so the implementation is the same whichever form the element turns out + // to have been written as. Which *admission* it runs under is decided per + // invocation below, from the engine's own account of the form. + const [first] = [...byForm.values()]; + if (first === undefined) { + throw new GeneratedXmdError(CONSTRUCT.component); + } + const entry = first.entry; const copy = retain(entry.definition); if (copy === undefined || copy.kind !== "function" || typeof copy.fn !== "function") { throw new GeneratedXmdError(CONSTRUCT.component); @@ -963,11 +1027,16 @@ class GeneratedImportAuthority implements ImportAuthority { const admitted: FunctionComponentDefinition = { ...copy, *fn(props, invocation) { - holdForm(form, invocation); + // Per invocation, because one authored element may be entered many + // times and two elements of one name may be written in two forms. The + // admission is selected by the form the engine issued for *this* + // element, and an element whose form the host admitted for no entry is + // refused here exactly as it always was. + holdForm(plannedForm(byForm, invocation).form, invocation); return yield* implementation(props, invocation); }, }; - this.#invocations.set(admitted.fn, planned); + this.#invocations.set(admitted.fn, byForm); // The wrapper above is the answer to the import; the dispatcher underneath // it is the form authority. Remembered by name so `authorize` can record it // against core's own copy — the object expansion actually invokes — because @@ -988,11 +1057,11 @@ class GeneratedImportAuthority implements ImportAuthority { invocation: ComponentInvocation, body: Operation, ): Operation { - const planned = typeof fn === "function" ? this.#invocations.get(fn) : undefined; - if (planned === undefined) { + const byForm = typeof fn === "function" ? this.#invocations.get(fn) : undefined; + if (byForm === undefined) { throw new GeneratedXmdError(CONSTRUCT.component); } - holdForm(planned.form, invocation); + holdForm(plannedForm(byForm, invocation).form, invocation); return yield* body; } @@ -1792,6 +1861,7 @@ function* walk( ceilings: ReadonlyMap, named: Planned[], scope: Set, + lexical: Lexical = ROOT_LEXICAL, ): Operation { for (const segment of segments) { switch (segment.type) { @@ -1805,6 +1875,15 @@ function* walk( throw new Refusal("block"); } case "component": { + // Structural names are language, not authority, so they are decided + // before the admitted table is consulted — exactly as expansion decides + // them before it resolves a component. A construct that reached the + // table lookup would be refused as a component the host withheld, which + // is a different and misleading thing to tell a candidate. + if (RESERVED_STRUCTURAL.has(segment.name)) { + yield* structural(segment, table, ceilings, named, scope, lexical); + break; + } const entries = table.get(segment.name); if (entries === undefined) { throw new Refusal("component"); @@ -1850,8 +1929,11 @@ function* walk( } named.push({ name: entry.name, identity: entry.identity, form, entry }); // The children see this element's bindings but not its own `as`, and - // nothing they bind escapes back to its siblings. - yield* walk(segment.children, table, ceilings, named, new Set(scope)); + // nothing they bind escapes back to its siblings. They are a component + // body rather than a transparent region, so the lexical facts reset: + // a `` written in a component's own body cannot break a loop + // that encloses the invocation. + yield* walk(segment.children, table, ceilings, named, new Set(scope), ROOT_LEXICAL); // After the element, because a component's result does not exist until // it has run: `` beside `` is ordered, // and `` names nothing. @@ -1871,6 +1953,311 @@ function* walk( } } +/** + * The lexical facts a construct is decided against. + * + * Only what a construct's own rule actually consults. `` is legal + * because a `` encloses it lexically — not because one is running — so + * that is a property of where it was written and travels down the walk. + */ +interface Lexical { + readonly insideLoop: boolean; +} + +/** + * What encloses the fragment's own top level. + * + * A generated fragment is not a component body and not a value body: there is + * no caller content for `` to claim, no output region for `` + * to select, and no value body for `` to answer. It is also not inside + * a loop. + */ +const ROOT_LEXICAL: Lexical = Object.freeze({ insideLoop: false }); + +/** A typed empty table, so an absent one reads as no expressions rather than as `{}`. */ +const NO_EXPRESSIONS: Record = {}; + +/** + * Every expression prop one element states, held to the fragment's own scope. + * + * Both readings of a brace, because the scanner splits them: a brace it could + * read as JSON becomes a resolved prop with the original token kept beside it, + * while anything else stays as expression text. Validating only the second + * would admit whatever the first silently rewrote — `{1e999}`, which JSON has + * no number for, resolves to `null` rather than refusing. + */ +function stated(segment: ComponentElement, scope: ReadonlySet): void { + const expressions = [ + ...Object.values(segment.expressions), + ...Object.values(segment.authoredExpressions ?? NO_EXPRESSIONS), + ]; + for (const expression of expressions) { + try { + validateDataExpression(expression, scope); + } catch { + throw new Refusal("expression"); + } + } +} + +/** The structural names that only their own parent may consume. */ +const CONSUMED_BY_PARENT: ReadonlySet = new Set(["Else", "Case", "Answer"]); + +/** + * The structural names the generated root supplies no context for. + * + * Each of these is ordinary language wherever its context exists, and a + * fragment is simply not that place. Refused as a structural mistake rather + * than as withheld authority, because nothing about the host's tables would + * make one of them work. + */ +const NO_GENERATED_CONTEXT: ReadonlySet = new Set(["Content", "Output", "Return"]); + +/** + * One structural construct, held to the ordinary source rules. + * + * The rules come from `structural-rules.ts` — the same module ordinary + * validation and expansion read — so a construct means one thing wherever it is + * written. What is different here is only *when*: every branch, every ``, + * every body and every path the run will not take is walked before the + * fragment's first effect, so a prohibited component in an untaken branch + * refuses the whole fragment rather than being discovered after an earlier read + * already happened. + * + * What stays at runtime stays at runtime. A condition, a matcher, a `max` an + * expression computes and a read that fails are values, and preflight proves + * the source and the authority of every possible path without fabricating one. + */ +function* structural( + segment: ComponentElement, + table: ReadonlyMap, + ceilings: ReadonlyMap, + named: Planned[], + scope: Set, + lexical: Lexical, +): Operation { + const name = segment.name; + // Consumed by the construct that gives them meaning, so reaching one here is + // an element written where that construct is not. + if (CONSUMED_BY_PARENT.has(name) || NO_GENERATED_CONTEXT.has(name)) { + throw new Refusal("structure"); + } + + // The construct's own props, over the bindings in effect where it was + // written. + stated(segment, scope); + + if (name === "Break") { + refuseStructure(breakViolations(segment, lexical.insideLoop)); + return; + } + + if (name === "If") { + refuseStructure(ifPropsViolation(segment)); + if (!("condition" in segment.props) && !("condition" in segment.expressions)) { + refuseStructure(ifConditionViolation(segment)); + } + const structure = ifStructure(segment); + refuseStructure(structure.violations); + // Both arms, whichever the condition would select, and each from the same + // incoming bindings. A prohibited component in the arm this run never + // enters still refuses the whole fragment. + yield* alternatives( + [{ body: structure.whenTrue }, { body: structure.whenFalse }], + table, + ceilings, + named, + scope, + lexical, + ); + return; + } + + if (name === "Switch") { + const structure = switchStructure(segment); + refuseStructure(structure.violations); + const branches = [...structure.matching, ...(structure.fallback ? [structure.fallback] : [])]; + // Each branch is an alternative to the others, so each is checked from the + // bindings that reach the `` — including its matcher, which is the + // branch's own expression and is validated whether or not the comparison + // would ever reach it. + yield* alternatives( + branches.map((branch) => ({ matcher: branch.element, body: branch.element.children })), + table, + ceilings, + named, + scope, + lexical, + ); + return; + } + + if (name === "Loop") { + refuseStructure(loopViolations(segment)); + // Inside for the body alone: a `` after the loop is as stray as one + // written where no loop ever was. + yield* transparent(segment.children, table, ceilings, named, scope, { + ...lexical, + insideLoop: true, + }); + return; + } + + if (name === "PrintErrors") { + refuseStructure(printErrorsViolations(segment)); + yield* transparent(segment.children, table, ceilings, named, scope, lexical); + return; + } + + if (name === "Answers") { + refuseStructure(answersViolations(segment)); + // Its `` children are the matchers; everything else is the region + // whose elicitations they answer. Both are walked — the matchers because + // which one is chosen is a runtime question, and the body because it is + // ordinary segments. + const body: Segment[] = []; + for (const child of segment.children) { + if (child.type === "component" && child.name === "Answer") { + refuseStructure(answerViolations(child)); + stated(child, scope); + yield* transparent(child.children, table, ceilings, named, scope, lexical); + continue; + } + if (!isBlankText(child)) { + body.push(child); + } + } + yield* transparent(body, table, ceilings, named, scope, lexical); + return; + } + + if (name === "Let") { + refuseStructure(letViolations(segment)); + yield* transparent(segment.children, table, ceilings, named, scope, lexical); + // After its own body, like a component's `as`: a `` cannot name the + // binding it is producing. + const bound = letBindingName(segment); + if (bound === undefined) { + throw new Refusal("structure"); + } + scope.add(bound); + return; + } + + if (name === "Each") { + refuseStructure(eachViolations(segment)); + const item = eachItemBinding(segment); + if (item === undefined) { + throw new Refusal("structure"); + } + // A fresh scope, seeded with what encloses the construct and the item this + // iteration names. Nothing the body binds survives it: the body runs once + // per item and may run no times at all, so a binding made inside it is not + // something a later sibling can be promised. What the construct offers its + // caller is the one capture it was asked for. + const body = new Set(scope); + body.add(item); + yield* walk(segment.children, table, ceilings, named, body, lexical); + const capture = eachCaptureBinding(segment); + if (capture !== undefined) { + scope.add(capture); + } + return; + } + + // Every structural name is either handled above or refused above, and + // `RESERVED_STRUCTURAL` is derived from the declarations, so a name added + // there without a rule here arrives as an ordinary structural refusal rather + // than as silently admitted syntax. + throw new Refusal("structure"); +} + +/** One of several regions the run chooses between, with its own matcher. */ +interface Alternative { + /** The element carrying the branch's own expression, when it has one. */ + readonly matcher?: ComponentElement; + readonly body: readonly Segment[]; +} + +/** + * Regions the run chooses *between*, checked from one incoming snapshot. + * + * The alternatives of an `` or a `` are mutually exclusive, so none + * of them may be checked against bindings another one produced: doing that in + * source order would let the second arm read a binding only the first makes, + * and admit a fragment whose second arm cannot run. Each is therefore walked + * from the bindings that reach the construct. + * + * What they may all contribute is what comes *after*. Once every alternative + * has been proved, the union of what they produce becomes visible past the + * construct — the accepted runtime-dependent behavior, where reading a binding + * only one alternative makes fails when the value is needed rather than at + * preflight. + */ +function* alternatives( + branches: readonly Alternative[], + table: ReadonlyMap, + ceilings: ReadonlyMap, + named: Planned[], + scope: Set, + lexical: Lexical, +): Operation { + const incoming: ReadonlySet = new Set(scope); + const outgoing = new Set(); + for (const branch of branches) { + if (branch.matcher !== undefined) { + stated(branch.matcher, incoming); + } + const region = new Set(incoming); + yield* walk(branch.body, table, ceilings, named, region, lexical); + for (const bound of region) { + if (!incoming.has(bound)) { + outgoing.add(bound); + } + } + } + for (const bound of outgoing) { + scope.add(bound); + } +} + +/** + * A region that runs in the environment enclosing it. + * + * `` arms, `` bodies, `` and `` bodies and a + * `` body are transparent: expansion expands them against the same live + * environment, so a binding one of them produces is visible after the construct + * and preflight says so too. Which arm actually ran is a runtime question, and + * a fragment that reads a binding only one arm produces fails where it always + * would — when the value is needed. + */ +function* transparent( + segments: readonly Segment[], + table: ReadonlyMap, + ceilings: ReadonlyMap, + named: Planned[], + scope: Set, + lexical: Lexical, +): Operation { + const region = new Set(scope); + yield* walk(segments, table, ceilings, named, region, lexical); + for (const bound of region) { + scope.add(bound); + } +} + +/** Refuse when the ordinary source rules found anything wrong. */ +function refuseStructure( + found: StructuralViolation | readonly StructuralViolation[] | undefined, +): void { + if (found === undefined) { + return; + } + if (Array.isArray(found) ? found.length > 0 : true) { + throw new Refusal("structure"); + } +} + const GENERATED_XMD = "generated_xmd"; /** diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index 86a1ffc4..ee8bb7bf 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -952,7 +952,11 @@ describe("Tier FE — what the element itself may say", () => { ["a calling expression prop", `\\n`], ["an interpolated binding", `\\n{binding}\\n`], ["a malformed `as` binding", `\\n`], - ["a structural construct", `\\n\\n\\n`], + // A well-formed construct around this read is admitted now (FE35). What + // stays refused is a construct the generated root supplies no context + // for, and one written where the construct that gives it meaning is not. + ["a construct with no generated context", `\\n\\n`], + ["a stray branch", `\\n\\n\\n`], ]; const outcomes: Array<[string, string[]]> = []; for (const [what, fragment] of refused) { @@ -1242,6 +1246,239 @@ describe("Tier FE — protection settles which implementation runs, and grants n }); }); +/** + * Tier FE35 — every built-in structural construct, inside a generated fragment. + * + * `allow` selects effect tables. It never selected the language, and these rows + * are what makes that observable: a fragment composes admitted reads with + * branching, binding and bounded iteration under exactly the ordinary rules, + * while the components those constructs reach stay held to the selected + * authority. + * + * The discriminating half is the untaken path. Preflight walks every branch, + * every `` and every body before the first effect, so a prohibited + * component in an arm this run would never enter refuses the whole fragment + * with the recorder's log still empty. + */ +describe("Tier FE35 — structural constructs in a generated fragment", () => { + it("FE35: branching, binding and bounded iteration compose with admitted reads", function* () { + const files = recordedFiles({ "notes.md": NOTE, "other.md": "second\n" }); + const output = yield* run( + `\\n` + + `\\n\\n` + + `\\n\\n` + + `\\n\\n\\nnot taken\\n\\n\\n'} ` + + `as="answer" />\n\n\n`, + [reading(files)], + ); + + const rendered = String(output); + expect(rendered).toContain("the retained note"); + expect(rendered).not.toContain("not taken"); + // One admitted read, reached through the constructs rather than around them. + expect(files.performed).toEqual(["read notes.md"]); + }); + + it("FE35: a prohibited component in an untaken branch refuses at zero reads", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const cases: Array<[string, string]> = [ + // The arm the condition never selects. The write is refused by form, + // which is the read selection speaking, not the branch. + [ + `\\n\\n` + + `\\nwritten\\n\\n\\n`, + "admitted only in its self-closing form", + ], + // The `` the matcher never reaches. + [ + `\\n\\n\\n\\n` + + `\\n\\n\\n\\n`, + "did not admit", + ], + ]; + for (const [fragment, expected] of cases) { + const failed = yield* refusal( + run(`\n`, [reading(files)]), + ); + expect(failed).toContain(expected); + } + // The whole point: the admitted read in the *taken* arm never happened, + // because the fragment was refused before any of it ran. + expect(files.performed).toEqual([]); + expect(files.entries.get("notes.md")).toBe(NOTE); + }); + + it("FE35: a body binding does not escape, and alternatives cannot feed each other", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const cases: string[] = [ + // An `` body may run no times at all, so what it binds is not + // something a later sibling can be promised. + `\\n\\n\\n` + + `\\n`, + // Two arms of one `` are alternatives: the arm that runs is the arm + // the other one did not, so neither can read the other's binding. + `\\n\\n` + + `\\n\\n\\n\\n`, + // The same for two `` branches, in source order and against the + // fallback alike. + `\\n\\n\\n\\n` + + `\\n\\n\\n\\n`, + ]; + for (const fragment of cases) { + const failed = yield* refusal( + run(`\n`, [reading(files)]), + ); + expect(failed).toContain("declarative data"); + } + // Refused whole, so the admitted read in each fragment never happened. + expect(files.performed).toEqual([]); + }); + + it("FE35: Loop, a valid Break and PrintErrors run around an admitted read", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const output = yield* run( + `\\n\\n\\n` + + `\\n\\n\\n'} as="answer" />\n\n\n`, + [reading(files)], + ); + + // Each of these has its own handler rather than falling through to the + // generic structural refusal: the loop ran, the break ended it after one + // pass, and the region rendered the read it wrapped. + expect(String(output)).toContain("the retained note"); + expect(files.performed).toEqual(["read notes.md"]); + }); + + it("FE35: an Each item does not escape, and its capture does", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + // The item binding belongs to the body. + const leaked = yield* refusal( + run( + `\\nx\\n\\n\\n'} />\n`, + [reading(files)], + ), + ); + expect(leaked).toContain("declarative data"); + + // The capture is written for the caller and survives the construct. + const captured = yield* run( + `\\nx\\n\\n` + + `\\n'} as="answer" />\n\n\n`, + [reading(files)], + ); + expect(String(captured)).toContain("x"); + expect(files.performed).toEqual([]); + }); + + it("FE35: every nested body is preflighted, including ones chosen at runtime", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const cases: Array<[string, string]> = [ + // A paired ``'s children are its template, and which answer is + // chosen is a runtime question — so every one of them is proved first. + [ + `\\n\\nApprove ?\\n\\n` + + `\\n\\n`, + "did not admit", + ], + // And the ordinary `` body — the region whose elicitations the + // matchers answer — is walked like any other region. + [ + `\\n\\n` + + `\\n\\n`, + "did not admit", + ], + ]; + for (const [fragment, expected] of cases) { + const failed = yield* refusal( + run(`\n`, [reading(files)]), + ); + expect(failed).toContain(expected); + } + expect(files.performed).toEqual([]); + + // A `` in a component's own body cannot break a loop that encloses + // the invocation, so it is stray even written inside one. `` is + // admitted paired here, so the refusal is about the `` rather than + // about the element hosting it. + const writable = recordedFiles({ "notes.md": NOTE }); + const stray = yield* refusal( + run( + `\\n\\n\\n\\n\\n'} ` + + `allow={["write"]} />\n`, + [both(writable)], + ), + ); + expect(stray).toContain("structural construct"); + expect(writable.performed).toEqual([]); + }); + + /** + * Preflight reads every alternative and every body once. The run does + * neither, so a plan is addressed by the element it was made for rather than + * consumed from a per-name queue in walk order. + */ + it("FE35: a selected write does not consume an untaken read's plan", function* () { + const files = recordedFiles({ "untaken.md": NOTE, "written.md": "before\n" }); + const output = yield* run( + `\\n\\n` + + `\\nwritten by the fragment\\n\\n\\n'} ` + + `allow={["read", "write"]} />\n`, + [both(files)], + ); + + expect(String(output)).toBeTruthy(); + // Exactly the selected write, whose own path check precedes it. The + // untaken read never ran, and the write did not fail a form check by + // taking the read's plan. + expect(files.performed).toEqual(["check written.md", "write written.md"]); + expect(files.entries.get("written.md")).toBe("written by the fragment"); + expect(files.entries.get("untaken.md")).toBe(NOTE); + }); + + it("FE35: one authored occurrence invoked twice reuses its own plan", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const output = yield* run( + `\\n\\n\\n'} ` + + `as="answer" />\n\n\n`, + [reading(files)], + ); + + // Two iterations of one element: the second import finds the same plan + // rather than an exhausted queue. + expect(files.performed).toEqual(["read notes.md", "read notes.md"]); + expect(String(output)).toContain("the retained note"); + }); + + it("FE35: a zero-iteration occurrence consumes no plan", function* () { + const files = recordedFiles({ "notes.md": NOTE, "after.md": "after\n" }); + const output = yield* run( + `\\n\\n\\n` + + `\\n'} as="answer" />\n\n\n`, + [reading(files)], + ); + + // The empty iteration invoked nothing, and the element after it still + // reached its own plan rather than one the loop was expected to spend. + expect(files.performed).toEqual(["read after.md"]); + expect(String(output)).toContain("after"); + }); + + it("FE35: executable code and imports stay refused inside a construct", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const cases: Array<[string, string]> = [ + ["```bash exec\\nprintf ran\\n```", "executable code block"], + ["{caller}", "interpolation"], + ]; + for (const [body, expected] of cases) { + const failed = yield* refusal( + run(`\\n${body}\\n\\n'} />\n`, [reading(files)]), + ); + expect(failed).toContain(expected); + } + expect(files.performed).toEqual([]); + }); +}); + /** * Tier FE34 — the shared read profile: File, Glob and canonical Syntax. * diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index e6ed789d..f2b87199 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -435,7 +435,13 @@ describe("Tier GX — the complete fragment is read first", () => { ["a frontmatter read", "value {props.token}\n", "interpolation"], ["a malformed result binding", `\n`, "binding name"], ["an unknown component", "\n", "did not admit"], - ["a structural construct", "x\n", "did not admit"], + // Structural constructs are the language rather than the host's tables, so + // an ill-formed one is refused as the source mistake it is — `test` is not + // a prop `` has — rather than as a component the host withheld. + ["an ill-formed structural construct", "x\n", "structural construct"], + ["a construct the generated root gives no context", "\n", "structural construct"], + ["a stray branch", "x\n", "structural construct"], + ["a break outside every loop", "\n", "structural construct"], ["an unadmitted root component", '\n', "did not admit"], ["an unadmitted repository component", '\n', "did not admit"], ["an unadmitted worktree component", '\n', "did not admit"], @@ -456,6 +462,45 @@ describe("Tier GX — the complete fragment is read first", () => { }); } + /** + * One authored element invoked twice, resumed between the two effects. + * + * Preflight reads the element once and the run enters it per item, so this is + * the row that would catch a plan consumed from a queue: the second iteration + * would find nothing left. It also holds the ordinary durable contract to the + * repetition — the completed effect restores and the run resumes at the first + * unrecorded one. + */ + it("GX10: a repeated occurrence resumes at its first unrecorded effect", function* () { + const source = `\n\n\n`; + const stream = new InMemoryStream(); + const live = yield* useTransport(() => ({ status: 200, body: "answer" })); + const first = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST])]), { stream }); + + // One authored element, entered twice, so two durable effects. + expect(live.performed).toHaveLength(2); + const events = yield* stream.readAll(); + const fetches = events.filter( + (event) => event.type === "yield" && event.description.type === "fetch", + ); + expect(fetches).toHaveLength(2); + + // Cut the history immediately after the first of the two. + const cut = events.indexOf(fetches[0]); + const partial = events.slice(0, cut + 1); + const resumedTransport = yield* useTransport(() => ({ status: 200, body: "answer" })); + const before = resumedTransport.performed.length; + const resumed = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST])]), { + stream: new InMemoryStream(partial), + }); + + // The recorded effect was restored rather than performed again, the second + // one ran, and the fragment rendered exactly what it rendered live. + expect(resumedTransport.performed.length - before).toBe(1); + expect(resumed.output).toBe(first.output); + expect(resumed.failure).toBe(undefined); + }); + it("GX4b: an unsafe construct inside an admitted element's content is refused", function* () { const transport = yield* useTransport(() => ({ status: 200 })); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 64a57334..5effe69b 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -4051,6 +4051,72 @@ bindings, object composition, structural language constructs or pure renderers. Imports and executable code fences remain outside the generated program's authority. +**Every built-in structural construct is available, with its ordinary +semantics.** `Content`, `Output`, `Return`, `Let`, `Each`, `If`, `Else`, +`Switch`, `Case`, `Loop`, `Break`, `PrintErrors`, `Answers` and `Answer` are +engine-owned language rather than component authority. A generated fragment +composes admitted reads with branching, binding and bounded iteration, and the +source rules deciding each construct are the same ones ordinary validation and +expansion read — one construct means one thing whether a person or an Agent +wrote it. + +Preflight dispatches a reserved structural name to those rules **before** it +consults the admitted table, so a construct is never reported as a component the +host withheld. It walks every branch, every ``, every iteration body and +every nested region — including a path the run will not take — before the +fragment's first effect. A prohibited component in an untaken branch therefore +refuses the whole fragment with no read performed. Every effectful component +reached through a construct remains held to the selected authority. + +Runtime-dependent behavior stays at runtime. A condition, a matcher, a `max` an +expression computes, and a read that fails are values: preflight proves the +source and the authority of every possible path without evaluating conditions or +fabricating values. + +**Runtime authorization selects the admission belonging to the authored element +being invoked.** Preflight reads every alternative and every body once, and the +run does neither: an `` or a `` enters one alternative, and an +`` or a `` may enter one authored element repeatedly or not at all. +An admission is therefore addressed by the element it was made for rather than +taken in the order preflight recorded it. An untaken alternative and a +zero-iteration body consume nothing; repeated invocations of one authored +element reuse that element's own admitted identity and form; and two elements +sharing a name cannot take one another's admissions. The retained record is +unchanged — a continuation re-derives the addressing by preflighting the +retained source, which yields the same elements in the same places. + +Binding ownership is stated per region. A component's children do not escape to +later siblings, and a component's own `as` becomes visible only to later +siblings. + +An `` body is a fresh scope seeded with the enclosing bindings and the +item binding. Nothing the body binds survives it — the body runs once per item +and may run no times at all — so only `` joins the enclosing scope. + +The alternatives of an `` and the `` branches of a `` are +mutually exclusive, so every arm, every matcher and every branch body is checked +from the same incoming bindings. One alternative therefore cannot supply a +binding to another. Once all of them are proved, the union of what they can +produce becomes visible after the construct, which preserves the +runtime-dependent behavior: reading a binding only one alternative makes fails +when the value is needed rather than at preflight. + +``, `` and `` bodies are transparent regions that read +and write the enclosing binding environment. + +A construct written where the generated root supplies no ordinary context fails +with its ordinary structural rule rather than as an unauthorized component. +``, `` and `` have no such context in a fragment: there +is no caller content to claim, no independent output region to select and no +value body to answer. ``, `` and `` are consumed by the +construct that gives them meaning, so reaching one directly is a stray element, +and `` requires a lexically enclosing ``. + +Admission records are unchanged. Constructs keep their exact spelling and +nesting in the retained source, never join the retained list of named component +identities, and the record version is unchanged. A historical refusal remains a +refusal on replay; only a fresh invocation obtains the new admission decision. + Generated expression props are declarative data expressions. Their recursive grammar admits null, string and boolean literals; finite JSON numbers; identifiers naming current fragment-local bindings; arrays; objects; and object @@ -11253,6 +11319,7 @@ through the captured capability because there is no other way to reach it. | FE31 | One fragment binds File and protected Syntax results locally and explicitly renders a chosen object through Json. Nested literal arrays and objects, binding shorthand, finite JSON numbers and a directly attached leading minus such as `` work in scalar, array and object positions. `1e999`, `-1e999`, `+1`, `-note`, `!note`, `typeof note`, an unbound identifier, call, operator, spread, computed property, template or global reference is refused before any earlier effect. | | FE32 | Generated admission and effect events follow ordinary persist-before-resume publication. A later refusal, failure, cancellation or interruption retains completed effects, and continuation resumes at the occurrence's first unrecorded effect; Evaluate adds no staging or rollback. | | FE33 | Each Evaluate occurrence retains its admission and generated effects under identities belonging to that occurrence; two occurrences cannot consume one another's retained work, and no caller-controlled context, stream, replay cursor, coroutine identifier or durable owner participates. A projected body installs observable structured cleanup, and later parent work proves that cleanup completed before it began. No distinct child cursor, child `Close`, staging, rollback, provisional publication or second settlement protocol is required. | +| FE35 | A fragment composes admitted reads with ``, ``, ``/`` and ``/`` under ordinary rules, performing exactly the reads the taken paths name. A prohibited component in an untaken `` arm or an unreached `` refuses the whole fragment with zero reads, and the refusal names the selected authority rather than the branch. An `` item binding does not escape its body while `` does, and a binding the body itself makes cannot be read after the construct. Neither arm of an `` nor either `` of a `` can read a binding another alternative produces; each such refusal performs no read. A `` holding a valid `` and a `` region around an admitted read completes, proving those constructs reach their own handlers rather than a generic structural refusal. A prohibited component inside a paired ``'s template children, and one in the ordinary `` body, are each refused before any effect. An untaken self-closing `` before a selected paired `` write does not surrender its admission to that write: the write performs, the read does not, and the untaken file is unchanged. A two-item `` around one `` performs the read twice with no import refusal, an empty iteration consumes no admission, and a continuation cut between the two repeated effects restores the completed one and resumes at the second. An executable fence and an interpolated caller binding stay refused inside a construct. An ill-formed construct, one the generated root gives no context for, a stray branch and a `` outside every `` are refused as structural mistakes rather than as withheld components. | | FE34 | One fragment binds Glob, File and canonical Syntax and renders chosen findings through Json; an unmatched search binds `[]`. A search written without `as`, a paired search, and a paired `` under a `read` selection each refuse before any operation. An unusable pattern refuses with the ordinary sentence before the search, and a provider failure becomes one safe sentence naming no path. The search reaches the captured operation and the captured working directory while a nearer `API.Files` provider is never consulted. Bare generated Syntax reports the composition table plus the selected read vocabulary and not the unselected write table; named Syntax describes a component that stays unavailable. Canonical Syntax resolves at core's own identity, and a middleware replacement of that answer refuses. Core answers for that one protected name, so an entry hand-written at ``'s canonical identity is refused as an answer nothing identified. `xmd run` and a `host="run"` child of `xmd test` state the same profile. | Each refusal case needs a negative control proving no producer, middleware @@ -11260,7 +11327,7 @@ answer, request, file mutation, or other program effect occurred. The implementation tiers carrying the elaborated evidence are `GX` for the durable protocol, the ceiling table and the profile's entry rules, `CIV` for what a provider's stated identity is bound to, and `FT` for `` itself. -FE31–FE34 join the existing Evaluate, generated-XMD and workflow-adapter tiers. +FE31–FE35 join the existing Evaluate, generated-XMD and workflow-adapter tiers. Journal evidence discriminates occurrence isolation, ordinary persist-before-resume replay and projection cleanup ordering; existing structured-concurrency and invocation failure behavior remains unchanged. FE34's