🧩 Admit every built-in construct to generated XMD (#762) - #790
Conversation
A generated fragment could name an admitted component and nothing else. Asking one to gather information means letting it branch, bind and iterate, so every built-in structural construct is now available inside generated evaluation with its ordinary semantics: Content, Output, Return, Let, Each, If, Else, Switch, Case, Loop, Break, PrintErrors, Answers and Answer. Constructs are not a distinct segment kind. `<If>` is a component segment whose name is reserved, and it was refused only because the admitted table misses it. So preflight now dispatches a reserved name to the structural rules at the top of the component case, before that lookup — mirroring expansion, which checks each structural name before it resolves a component. That ordering is what makes a construct fail as the source mistake it is rather than as a component the host withheld, and the two are different things to tell a candidate: a new refusal class says the language does not allow the construct where it was written. The rules come from `structural-rules.ts` rather than a second copy, so a construct means one thing whether a person or an Agent wrote it. Preflight walks every branch, every Case, every iteration body, every Answer template and the Answers body before the first effect, so a prohibited component in a path the run never takes refuses the whole fragment with no read performed. What a value decides stays at runtime: conditions, matchers, a computed `max` and a failing read are evaluated where they always were. Binding ownership is stated per region rather than shared by all of them. An `<Each>` 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 `<Each as>` joins the enclosing scope. The alternatives of an `<If>` and the `<Case>` branches of a `<Switch>` are mutually exclusive, so every arm, matcher and branch body is checked from one incoming snapshot and no alternative can supply a binding to another; the union of what they can produce becomes visible after the construct, which keeps reading a binding only one arm makes a runtime failure rather than a preflight refusal. `<Loop>`, `<PrintErrors>` and `<Let>` bodies read and write the enclosing environment. A component body resets the lexical facts, so a `<Break>` written there cannot break an enclosing loop, and a component's own `as` is visible only to later siblings. Nothing durable moves. Constructs keep their exact spelling in the retained source, never join the retained named-component list, and the record version is unchanged. No product behavior changes. Two existing rows stated the contract this replaces and were retargeted rather than weakened. GX4 expected a structural construct to be refused as a component the host did not admit; it now expects the structural refusal, with rows added for a construct the generated root gives no context for, a stray branch and a stray break. FE6 listed a well-formed `<If>` among the refusals, which is precisely what this admits; it now uses `<Content />` and a stray `<Else>`.
| } | ||
|
|
||
| // The construct's own props, over the bindings in effect where it was | ||
| // written. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // written. |
| // Its `<Answer>` 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // ordinary segments. |
| refuseStructure(letViolations(segment)); | ||
| yield* transparent(segment.children, table, ceilings, named, scope, lexical); | ||
| // After its own body, like a component's `as`: a `<Let>` cannot name the | ||
| // binding it is producing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // binding it is producing. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // caller is the one capture it was asked for. |
PR #790: 🧩 Admit every built-in construct to generated XMD (#762)6 files, +801 / -28 Scope🔴 PR has 829 lines changed. Split into focused PRs. 🟡 829 lines changed. PRs under 400 receive more thorough review. Structural✅ No structural bloat detected. Slop
Static AnalysisOxlint: 4 diagnostics across 1 file (1 rule) no-shadow (4): packages/core/src/generated-xmd.ts CorrectnessNo extraneous code patterns detected. |
| // take an untaken read's plan, and a second iteration of one element would | ||
| // find nothing left. Looking the element up by where it was written is what | ||
| // makes repetition, selection and an empty iteration all correct, and it | ||
| // consumes nothing, so an untaken element's plan is still simply unused. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // consumes nothing, so an untaken element's plan is still simply unused. |
| } | ||
|
|
||
| // The construct's own props, over the bindings in effect where it was | ||
| // written. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // written. |
| // Its `<Answer>` 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // ordinary segments. |
| refuseStructure(letViolations(segment)); | ||
| yield* transparent(segment.children, table, ceilings, named, scope, lexical); | ||
| // After its own body, like a component's `as`: a `<Let>` cannot name the | ||
| // binding it is producing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // binding it is producing. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // caller is the one capture it was asked for. |
Whole-fragment preflight reads every alternative and every authored body once. The run does neither: an `<If>` or a `<Switch>` enters one alternative, and an `<Each>` or a `<Loop>` may enter one authored element many times or not at all. Runtime authorization was a per-name queue that `shift()`ed once per import, so it assumed a cardinality and an order the run does not have. Two consequences, both wrong for the same reason. An untaken self-closing `<File />` written before a selected paired `<File>` write handed that write the read's admission, which then failed the form check. And a two-item `<Each>` around one `<File />` exhausted the queue after the first iteration, so the second could not import File at all. Admissions are now held by name *and* authored form, and nothing is consumed. A name and a form select exactly one admission because the table already says so: `admitted()` refuses one name holding two definitions, and refuses one name and form twice. Which admission an invocation runs under is decided per invocation from the engine's own issuance — a wrapper can mint an object carrying a `hasContent` method and cannot mint an issuance — so an untaken alternative and a zero-iteration body spend nothing, a repeated invocation of one element finds its own identity and form, and an element whose form the host admitted for no entry is refused with the diagnostic a mismatched invocation always produced. The obvious alternative — keying by the element's authored position — is wrong, and CI proved it. `Component.importComponent` middleware may honestly delegate with `next(name)`, which drops the position, and the generated import then matched nothing: WGAC7's observing handler and FE24's honest delegation both failed. Authorization may not depend on a value a legitimate handler is free to omit. The form arrives on the engine's issuance instead, which no handler composes around. The record is untouched. Identities and forms are retained exactly as before, and preflight is unchanged: every alternative and nested body is still validated before the first effect, so a prohibited component in a path the run never takes still refuses the whole fragment with no effect performed. `packages/cli/tests/plan-component.test.ts` FE9 used a `<Loop>` around an admitted read as an example of text a fragment may not contain, which is what this stack admits. It now uses an executable fence after an admitted read, so the empty recorder still proves whole-fragment preflight. The comment claiming imports happen once per element and in walk order is replaced, because structural repetition makes both halves false.
402fccf to
d32141c
Compare
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // one thing whether a person wrote it or an Agent did. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // withheld something. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // invocation below, from the engine's own account of the form. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // refused here exactly as it always was. |
| // nothing they bind escapes back to its siblings. They are a component | ||
| // body rather than a transparent region, so the lexical facts reset: | ||
| // a `<Break>` written in a component's own body cannot break a loop | ||
| // that encloses the invocation. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // that encloses the invocation. |
| // Each branch is an alternative to the others, so each is checked from the | ||
| // bindings that reach the `<Switch>` — including its matcher, which is the | ||
| // branch's own expression and is validated whether or not the comparison | ||
| // would ever reach it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // would ever reach it. |
| if (name === "Loop") { | ||
| refuseStructure(loopViolations(segment)); | ||
| // Inside for the body alone: a `<Break>` after the loop is as stray as one | ||
| // written where no loop ever was. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // written where no loop ever was. |
| // Its `<Answer>` 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // ordinary segments. |
| refuseStructure(letViolations(segment)); | ||
| yield* transparent(segment.children, table, ceilings, named, scope, lexical); | ||
| // After its own body, like a component's `as`: a `<Let>` cannot name the | ||
| // binding it is producing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // binding it is producing. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // caller is the one capture it was asked for. |
Why
An information request has to be able to decide something — read a file only
if a path matched, render one shape or another, walk a list. A generated
fragment could name an admitted component and nothing else, so none of that was
expressible.
This is PR 2 of the three-PR stack for #762, on top of the merged PR 1
(#789). It adds the language; the Plan classifier and information loop are PR 3.
What changes
Before:
<If condition={true}><File path="a.md" /></If>was refused, and the refusalsaid the host did not admit
<If>.After:
ordinary semantics:
Content,Output,Return,Let,Each,If,Else,Switch,Case,Loop,Break,PrintErrors,Answers,Answer.withheld authority.
How it works
The load-bearing discovery: constructs are not a distinct segment kind.
<If>is acomponentsegment whose name is reserved, and it was refused onlybecause
table.get("If")misses. So the dispatch goes at the top of thecomponent case, before that lookup — mirroring
expand.ts, which checks eachstructural name before resolving a component. Any later and a construct is
reported as a component the host withheld, which is a different and misleading
thing to tell a candidate.
The rules themselves come from
structural-rules.ts— the module ordinaryvalidation and expansion already read — so a construct means one thing whether a
person or an Agent wrote it. What differs is only when: every branch, every
<Case>, every body and every path the run will not take is walked before thefragment's first effect.
Review guide
Start with:
packages/core/src/generated-xmd.ts→structural()Then review:
walk()'s"component"casealternatives()— the incoming-snapshot rule for<If>/<Switch><Each>arm ofstructural()— the fresh body scopetransparent(), and theROOT_LEXICALreset on component childrenLook carefully at:
it is deliberately not uniform. See the table under What must stay true.
<Answers>. Its<Answer>children are matchers; everything else is thebody region they answer for. Both are walked. An earlier revision of this
branch refused the body outright, which would have refused every legitimate
<Answers>.maxand afailing read are still evaluated where they always were.
What must stay true
<Each>body<Each as><If>arms,<Switch><Case>s<Loop>,<PrintErrors>,<Let>bodies<Loop><Break>there cannot break the enclosing loopAlso unchanged, and checked by the existing GX tiers: the durable record shape
and version, constructs staying out of the retained named-component list, and
every effectful component reached through a construct staying held to the
selected authority.
How to verify it
deno task test packages/core/tests/generated-composition.test.ts \ packages/core/tests/generated-xmd.test.ts \ packages/core/tests/evaluate-component.test.ts32 passed (291 steps) | 0 faileddeno task lintdeno task checkgit diff --checkWhat the new rows prove, and the defect each rejects:
FE35: branching, binding and bounded iteration compose with admitted reads— the positive path. Fails if a construct falls through to thegeneric structural refusal.
FE35: a prohibited component in an untaken branch refuses at zero reads—the discriminating row. The taken arm holds an admitted read; the untaken arm
holds a write.
files.performedmust be[], so preflight that walked onlythe selected branch fails here. Each refusal asserts its reason, so a
refusal for an unrelated cause does not pass.
FE35: a body binding does not escape, and alternatives cannot feed each other— three fragments that were all admitted before the latestrevision: an
<Each>body binding read afterwards, an<Else>reading thetrue arm's binding, and a
<Case default>reading an earlier<Case>'s.FE35: Loop, a valid Break and PrintErrors run around an admitted read—proves those three reach their own handlers rather than a generic refusal.
FE35: every nested body is preflighted— a prohibited component inside apaired
<Answer>'s template children, and one in the ordinary<Answers>body. Both were holes in an earlier revision of this branch.
FE35: executable code and imports stay refused inside a construct— aconstruct is not a way in.
Scope
Included
structural()walker ingenerated-xmd.tsstructurerefusal class, distinct fromcomponentalternatives(),transparent(),Lexical,stated()FE35acceptance row, and the architectureparagraphs
Intentionally unchanged
are untouched; constructs keep their exact spelling in retained source.
xmd runorxmd planchanges.allowstill selects effecttables only.
New abstractions
structural()exists because the reserved names need a dispatch point beforethe admitted table; one consumer,
walk().alternatives()exists because<If>and<Switch>share one rule thatdiffers from a transparent region's; two consumers.
Lexicalcarries the single lexical fact a rule consults (insideLoop).stated()replaced three inline copies of the two-brace-reading validation.Risks and limitations
the accepted contract as revised, and the three zero-effect regressions pin
them. Worth confirming they are the intended semantics rather than assuming.
not weakened.
GX4expected a structural construct to be refused as acomponent the host did not admit; it now expects the structural refusal, with
rows added for a construct the generated root gives no context for, a stray
branch and a stray break.
FE6listed a well-formed<If>among itsrefusals — precisely what this admits — and now uses
<Content />and astray
<Else>.<Answer>template children are walked, so a template containing textthat scans as a component is refused rather than treated as literal template
text. Fail-closed, and the alternative risks a real hole.
Scope confirmation