From dcaaf132970b1b4a381e5c43ac3d41f1637f984c Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:50:54 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Validate=20a=20supplied=20docum?= =?UTF-8?q?ent=20without=20executing=20it=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `validateDocument()`: one core operation that reads a supplied root and the Markdown source closure ordinary component selection discovers, and answers with deterministic version-1 data — one closed code per diagnostic, the normalized schema issues execution already produces, and one record per authored invocation saying whether it is valid, invalid, or not decidable without running the document. Traversal follows source rather than execution reachability: the selected root projection first, then definitions in FIFO discovery order, each source identity read once, so a cycle terminates and is not a failure. An invocation inside authored control flow is checked like any other, and no branch is evaluated to decide whether it would run. The rules come from execution rather than a second catalog. The body output/return contract moves to `body-structure.ts`, and the static parts of every structural construct move to `structural-rules.ts`; expansion renders the same facts as the printed errors it always produced. Frontmatter and definition parsing report the phase a failure belongs to, so nothing classifies an error by reading its message. The declaration-only registry inspection built is now shared. Observation performs no document effect: no expression is evaluated, no component invoked, no repository module imported, no identity factory called, no provider installed, no journal opened, and nothing the document authored is written. --- architecture.md | 75 ++ packages/core/mod.ts | 13 + packages/core/src/answers.ts | 96 +- packages/core/src/body-structure.ts | 314 +++++ .../core/src/components/declared-registry.ts | 51 + packages/core/src/definition.ts | 218 +++- packages/core/src/document-validation.ts | 1119 +++++++++++++++++ packages/core/src/expand.ts | 699 ++-------- packages/core/src/frontmatter.ts | 102 +- packages/core/src/inspect.ts | 40 +- packages/core/src/structural-rules.ts | 734 +++++++++++ .../core/tests/document-validation.test.ts | 861 +++++++++++++ specs/executable-mdx-spec.md | 165 +++ 13 files changed, 3769 insertions(+), 718 deletions(-) create mode 100644 packages/core/src/body-structure.ts create mode 100644 packages/core/src/components/declared-registry.ts create mode 100644 packages/core/src/document-validation.ts create mode 100644 packages/core/src/structural-rules.ts create mode 100644 packages/core/tests/document-validation.test.ts diff --git a/architecture.md b/architecture.md index 5b6c9d584..4b8714540 100644 --- a/architecture.md +++ b/architecture.md @@ -3429,6 +3429,80 @@ nothing. First-party declarations hold themselves to a stricter rule — every o states a description and decides explicitly whether `as` and `context` apply — which is a discipline inside those packages, not a requirement on anyone else. +## The document validation boundary + +Validate a supplied document as authored program structure before any part of it +runs. The operation receives one `RootDocumentSource`, root props, contextual +working directory and includes, the current component registry, and the same +plain identity-component declarations syntax inspection receives. Those values +are the declarative execution environment: validation never runs an +`ExecutionInstallation`, calls an identity factory, installs an operational +provider or reconstructs a CLI host to learn what names mean. + +**Validation follows source, not execution reachability.** It applies ordinary +target selection to the root, scans that selected projection, and follows normal +component selection through every resolved Markdown definition the authored +invocations discover. The root is first; newly discovered definitions enter one +FIFO queue in their first-invocation order, and a source identity is scanned only +once. Every invocation in each source is checked in source order, including one +inside authored control flow and children beneath an origin-only TypeScript +component. The validator neither evaluates a branch nor predicts whether a +component will project its children. A definition cycle therefore terminates +the source walk and is not itself a validation failure unless a shared authored +rule already makes it one. + +**A static answer never stands in for a runtime value.** Resolution, authored +form, engine-owned body shape and other structural rules run whenever their +answer does not depend on evaluating document code. Full props-schema validation +runs only when every schema-visible prop is static. A declared capture keeps the +same schema bypass it has during execution. A dynamic schema-visible expression +or an origin-only TypeScript contract makes an otherwise acceptable invocation +not statically checkable; validation does not partially solve JSON Schema around +the unknown value. A definite independent failure still wins, so an invalid form +does not become opaque because another prop is dynamic. An invocation is valid +only when every applicable check is statically proven, invalid when any such +check proves a failure, and otherwise not statically checkable. Opacity alone +does not make the document invalid. + +**Parsing stays one language rule.** Validation uses the scanner and definition +parsers execution uses. Component-like text that scanner treats as text remains +text, and spread attributes retain their execution meaning. A stricter MDX +grammar is a change to execution and validation together, never an extra parser +used only to approve generated source. Component selection, declaration +admission, schemas, forms, body rules, targets and source positions likewise +come from their execution definitions. If a check needs extraction from +expansion, both callers use the extracted rule; validation does not copy it into +a second catalog. + +**The answer is versioned data.** Version 1 returns the document outcome, one +ordered invocation record per authored site, and one ordered diagnostic array. +An invocation records its name and position, the selected origin when one was +resolved, and exactly one of `valid`, `invalid` or +`not-statically-checkable`. An invalid record points into the diagnostic array; +an opaque record names `dynamic-props`, `origin-only-contract`, or both as its +reason. Diagnostics carry one closed code, a message, the source position and +component when applicable, and the existing normalized schema issues when a +schema failed. The closed version-1 codes distinguish unreadable and invalid +source, invalid target, frontmatter, props and returns declarations, unresolved +and ambiguous components, invalid invocation form and body shape, props, +binding, capture, return usage and structural usage. + +Ordering is part of that answer: the root precedes definitions from the FIFO +source walk, positions order records within one source, and the closed diagnostic +code order breaks a tie at one position. A source failure ends only the checks +that require the source or definition it prevented; the validator emits no +speculative follow-on error. The document outcome is invalid exactly when a +definite diagnostic exists. The stable codes and normalized schema issues are +what an ACP generator or another host consumes; neither parses the message. + +**Observation performs no document effect.** Validation may read the supplied +root and selected Markdown definition files. It does not evaluate an expression, +render or project component content, invoke a component, create or read a +journal, run a command, prompt, elicit, start an agent, or perform a filesystem +operation the document authored. Those prohibitions are one boundary rather +than a test fixture's list of components: adding a component cannot make +validation effectful. + ## Construct inventory Status is measured against main. @@ -3436,6 +3510,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-1 JSON, from one catalog. 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 | built on the #632 stack | +| 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 #653 stack | | `` / `printErrors(fn)` | prints failures | built on main | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | | `` | renders one supplied value as JSON text where the element was written, from one native two-space `JSON.stringify` call. An ordinary overridable core default whose operand is a capture: the exact evaluation result arrives by reference and is never mutated, cloned, replaced or frozen. It binds nothing, and `as`, content and a missing `value` are all refused before the operand evaluates. A value with no JSON text and a serialization that threw are distinct failures, each positioned at the invocation, emitting no partial output and preserving the original error as its cause. No scope, resource, authority or JSON-specific durable effect: replay reaches it through ordinary expansion, and a surrounding `` or `` keeps its own record of the text it consumed | built on the #452 stack | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d91c8f547..8d046ca9e 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -213,6 +213,19 @@ export type { SyntaxCatalog, } from "./src/inspect.ts"; export { ComponentIncludeError } from "./src/components/candidates.ts"; +// Document validation — one supplied document read as authored program +// structure, with nothing in it executed. +export { documentValidationCodeRank, validateDocument } from "./src/document-validation.ts"; +export type { + DocumentValidation, + DocumentValidationCode, + DocumentValidationDiagnostic, + InvocationOpacityReason, + InvocationSite, + InvocationValidation, + ValidateDocumentOptions, + ValidateDocumentSettings, +} from "./src/document-validation.ts"; // Component registration — scope-local names resolved ahead of package defaults. export { diff --git a/packages/core/src/answers.ts b/packages/core/src/answers.ts index ee59990cf..547789618 100644 --- a/packages/core/src/answers.ts +++ b/packages/core/src/answers.ts @@ -88,6 +88,18 @@ import { matchPrompt, parseTemplate, resolveBinding } from "./template.ts"; import type { ParsedTemplate } from "./template.ts"; import type { ComponentElement, ErrorSegment, Json, Segment } from "./types.ts"; import type { AnswersPlacement, DeclarationScanner } from "./declaration-scan.ts"; +import { + answerMissingValueMessage, + answerPropMessage, + answersDelegateMessage, + answersLiteralDelegateMessage, + answersNoBodyMessage, + answersPropMessage, + answerTemplateBothMessage, + answerTemplateExpressionMessage, + isBlankText as isBlankSegment, + strayAnswerMessage, +} from "./structural-rules.ts"; /** * The enclosing expansion's recursion, handed in by the arm that dispatched the @@ -151,23 +163,23 @@ function configError(name: string, message: string, element: ComponentElement): return { type: "error", message: positioned(`<${name}> ${message}`, element), source: name }; } -function isAnswer(segment: Segment): segment is ComponentElement { - return segment.type === "component" && segment.name === ANSWER; +/** + * A shared structural violation as the printed error this module produces. + * + * The violation's sentence already names its construct, because validation + * reports the same sentence with no element to prefix it from. + */ +function violationError(message: string, name: string, element: ComponentElement): ErrorSegment { + return { type: "error", message: positioned(message, element), source: name }; } -/** Markdown puts blank lines between block elements; they are not a body. */ -function isBlankText(segment: Segment): boolean { - return segment.type === "text" && segment.content.trim() === ""; +function isAnswer(segment: Segment): segment is ComponentElement { + return segment.type === "component" && segment.name === ANSWER; } /** A `` written outside the `` that would have read it. */ export function strayAnswerError(element: ComponentElement): ErrorSegment { - return configError( - ANSWER, - "must be a direct child of . It is reserved: it never resolves a " + - "component, and only the it belongs to can read it.", - element, - ); + return violationError(strayAnswerMessage(), ANSWER, element); } /** What one `` element holds, before anything is decided about it. */ @@ -220,16 +232,12 @@ export function* expandAnswers( ): Operation { for (const name of Object.keys({ ...element.props, ...element.expressions })) { if (name !== "delegate") { - return [ - yield* raise( - configError(ANSWERS, `does not accept a "${name}" prop (allowed: delegate).`, element), - ), - ]; + return [yield* raise(violationError(answersPropMessage(name), ANSWERS, element))]; } } const delegate = yield* readDelegate(element); if (delegate.error) { - return [yield* raise(configError(ANSWERS, delegate.error, element))]; + return [yield* raise(violationError(delegate.error, ANSWERS, element))]; } const partitioned = yield* partition(element, expand); @@ -241,17 +249,8 @@ export function* expandAnswers( } const { matchers, body } = partitioned; - if (element.selfClosing || body.every(isBlankText)) { - return [ - yield* raise( - configError( - ANSWERS, - "has no body to answer for. It wraps the region whose elicitations it answers, so " + - "an containing only matchers can never do anything.", - element, - ), - ), - ]; + if (element.selfClosing || body.every(isBlankSegment)) { + return [yield* raise(violationError(answersNoBodyMessage(), ANSWERS, element))]; } const bindings = (yield* env)?.values ?? {}; @@ -313,16 +312,12 @@ function* readChildAnswers( ): Operation> { for (const name of Object.keys({ ...element.props, ...element.expressions })) { if (name !== "delegate") { - return Err( - new Error( - positioned(`<${ANSWERS}> does not accept a "${name}" prop (allowed: delegate).`, element), - ), - ); + return Err(new Error(positioned(answersPropMessage(name), element))); } } const delegate = yield* readDelegate(element); if (delegate.error) { - return Err(new Error(positioned(`<${ANSWERS}> ${delegate.error}`, element))); + return Err(new Error(positioned(delegate.error, element))); } if (delegate.value) { return Err( @@ -351,7 +346,7 @@ function* readChildAnswers( ), ); } - if (!body.every(isBlankText)) { + if (!body.every(isBlankSegment)) { return Err( new Error( positioned( @@ -521,7 +516,7 @@ function* readAnswer( ): Operation { for (const name of Object.keys({ ...element.props, ...element.expressions })) { if (name !== "template" && name !== "value") { - return refuse(element, `does not accept a "${name}" prop (allowed: template, value).`); + return refuseAnswer(element, answerPropMessage(name)); } } @@ -530,17 +525,13 @@ function* readAnswer( // of everything below it. `` reaches its "requires a template" // error by the same route; this says so directly. if ("template" in element.expressions) { - return refuse( - element, - "template must be a literal string prop or template children, not an expression. " + - "Write the bindings a template references as {binding} holes inside it.", - ); + return refuseAnswer(element, answerTemplateExpressionMessage()); } const templateProp = element.props.template; const hasChildren = !element.selfClosing && element.children.length > 0; if (typeof templateProp === "string" && hasChildren) { - return refuse(element, "accepts either a template prop or template children, not both."); + return refuseAnswer(element, answerTemplateBothMessage()); } let template: ParsedTemplate | undefined; @@ -565,7 +556,7 @@ function* readAnswer( } if (!("value" in element.props) && !("value" in element.expressions)) { - return refuse(element, 'requires a "value" prop.'); + return refuseAnswer(element, answerMissingValueMessage()); } const value = yield* readValue(element); if (value.error) { @@ -575,6 +566,11 @@ function* readAnswer( return template ? { template, value: value.parsed } : { value: value.parsed }; } +/** One shared `` sentence as this module's malformed-matcher answer. */ +function refuseAnswer(element: ComponentElement, message: string): Malformed { + return { error: violationError(message, ANSWER, element) }; +} + function refuse(element: ComponentElement, message: string): Malformed { return { error: configError(ANSWER, message, element) }; } @@ -641,12 +637,15 @@ function* readDelegate(element: ComponentElement): Operation<{ value: boolean; e try { evaluated = yield* evaluateExpression(expression, ANSWERS, "delegate", element.projectedEnv); } catch (error) { - return { value: false, error: error instanceof Error ? error.message : String(error) }; + return { + value: false, + error: `<${ANSWERS}> ${error instanceof Error ? error.message : String(error)}`, + }; } if (typeof evaluated !== "boolean") { return { value: false, - error: `delegate must be a boolean, and {${expression}} is ${typeof evaluated}.`, + error: answersDelegateMessage(`{${expression}} is ${typeof evaluated}.`), }; } return { value: evaluated }; @@ -656,12 +655,7 @@ function* readDelegate(element: ComponentElement): Operation<{ value: boolean; e } const raw = element.props.delegate; if (typeof raw !== "boolean") { - return { - value: false, - error: `delegate must be a boolean — write delegate={true}, not delegate=${JSON.stringify( - raw, - )}.`, - }; + return { value: false, error: answersLiteralDelegateMessage(raw) }; } return { value: raw }; } diff --git a/packages/core/src/body-structure.ts b/packages/core/src/body-structure.ts new file mode 100644 index 000000000..c0ff0c689 --- /dev/null +++ b/packages/core/src/body-structure.ts @@ -0,0 +1,314 @@ +/** + * The body contract a definition's own source states (spec §6.9, §6.10). + * + * A body either renders markdown, in which case `` may restrict what it + * renders and `` has no declaration to satisfy, or it declares + * `returns`, in which case it renders nothing and produces exactly one value. + * Which one it is, and what the source got wrong about it, is read from the + * body's own segment tree and from nothing else — before `` + * substitution, so projected content can neither introduce nor satisfy a + * declaration. + * + * The facts are separated from the sentences here because two callers need + * different things from one rule. Expansion needs the aggregate printed error + * it has always produced, and renders it below. Validation needs each violation + * on its own, at the position it was authored, under its own code. Both read + * the same walk and the same catalog, so a body cannot be acceptable to one and + * refused by the other. + */ + +import type { ComponentElement, ReturnsSchema, Segment, TextSegment } from "./types.ts"; +import type { ErrorSegment } from "./types.ts"; + +/** One thing a `` element itself got wrong. */ +export interface ReturnElementViolation { + readonly element: ComponentElement; + readonly message: string; +} + +/** + * What one body's source says about its own output and return contract. + * + * Every member is the *violations* found, so an empty set of facts is a body + * whose structure holds. Which members can be populated depends on the mode: + * the two are exclusive contracts, not two halves of one. + */ +export interface BodyStructureFacts { + readonly mode: "text" | "value"; + /** Text mode: every `` written below the top level. */ + readonly misplacedOutputs: readonly ComponentElement[]; + /** Text mode: every `` a body with no `returns` declaration wrote. */ + readonly undeclaredReturns: readonly ComponentElement[]; + /** Value mode: every ``, at any depth — `returns` excludes all of them. */ + readonly exclusiveOutputs: readonly ComponentElement[]; + /** Value mode: the body declares `returns` and writes no `` at all. */ + readonly missingReturn: boolean; + /** Value mode: what each `` element itself got wrong. */ + readonly returnViolations: readonly ReturnElementViolation[]; +} + +function isTopLevelOutput(segment: Segment): boolean { + return segment.type === "component" && segment.name === "Output"; +} + +export function bodyHasOutput(bodySegments: Segment[]): boolean { + return bodySegments.some(isTopLevelOutput); +} + +/** Every `` at or below `minimumDepth`, in source order. */ +function collectOutputs(bodySegments: Segment[], minimumDepth: number): ComponentElement[] { + const found: ComponentElement[] = []; + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Output" && depth >= minimumDepth) { + found.push(segment); + } + walk(segment.children, depth + 1); + } + }; + walk(bodySegments, 0); + return found; +} + +/** + * Every `` the body itself declares, at any depth. + * + * Depth is not a violation: a return under `` or inside a `` is + * written in the body's own flow and is reached by ordinary expansion. What + * this walk does not see is the only thing that still cannot declare one — + * another component's definition, and markdown produced at runtime — because + * it reads this body's source AST and nothing else. + */ +function collectReturns(bodySegments: Segment[]): ComponentElement[] { + const declared: ComponentElement[] = []; + + const walk = (segments: Segment[]): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Return") { + declared.push(segment); + } + walk(segment.children); + } + }; + + walk(bodySegments); + return declared; +} + +export function previewOutput(segment: ComponentElement): string { + const text = segment.children + .filter((child): child is TextSegment => child.type === "text") + .map((child) => child.content) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (text.length === 0) { + return " (empty)"; + } + const clipped = text.slice(0, 40); + return ` containing "${clipped}${text.length > 40 ? "…" : ""}"`; +} + +export function previewReturn(segment: ComponentElement): string { + if ("value" in segment.expressions) { + return ``; + } + if ("value" in segment.props) { + return ``; + } + return ""; +} + +function returnElementViolations(segment: ComponentElement): string[] { + const violations: string[] = []; + const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; + const extra = names.filter((name) => name !== "value"); + if (extra.length > 0) { + violations.push(`${previewReturn(segment)} accepts only a "value" prop, got "${extra[0]}"`); + } + if (!names.includes("value")) { + violations.push(`${previewReturn(segment)} requires a "value" prop`); + } + if (segment.children.length > 0) { + violations.push(`${previewReturn(segment)} takes no children`); + } + return violations; +} + +/** + * Read one body's output and return contract from its own source AST. + * + * Pure, and free of effects: nothing here evaluates an expression, resolves a + * name, or reads a file, so the same body always produces the same facts. + */ +export function bodyStructureFacts( + bodySegments: Segment[], + returns: ReturnsSchema | undefined, +): BodyStructureFacts { + if (returns !== undefined) { + const declared = collectReturns(bodySegments); + const returnViolations: ReturnElementViolation[] = []; + for (const element of declared) { + for (const message of returnElementViolations(element)) { + returnViolations.push({ element, message }); + } + } + return { + mode: "value", + misplacedOutputs: [], + undeclaredReturns: [], + exclusiveOutputs: collectOutputs(bodySegments, 0), + missingReturn: declared.length === 0, + returnViolations, + }; + } + return { + mode: "text", + misplacedOutputs: collectOutputs(bodySegments, 1), + undeclaredReturns: collectReturns(bodySegments), + exclusiveOutputs: [], + missingReturn: false, + returnViolations: [], + }; +} + +/** Whether these facts describe a body whose structure is refused. */ +export function hasBodyStructureViolation(facts: BodyStructureFacts): boolean { + return ( + facts.misplacedOutputs.length > 0 || + facts.undeclaredReturns.length > 0 || + facts.exclusiveOutputs.length > 0 || + facts.missingReturn || + facts.returnViolations.length > 0 + ); +} + +function structureError(source: string, headline: string, violations: string[]): ErrorSegment { + const list = violations.map((entry) => ` - ${entry}`).join("\n"); + return { type: "error", message: `${headline}\n${list}`, source }; +} + +function misplacedOutputAggregate( + misplaced: readonly ComponentElement[], +): ErrorSegment | undefined { + if (misplaced.length === 0) { + return undefined; + } + const list = misplaced.map((segment) => ` - ${previewOutput(segment)}`).join("\n"); + return { + type: "error", + message: + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside " + + `. Misplaced found:\n${list}`, + source: "Output", + }; +} + +/** + * The one printed error expansion has always produced for these facts, or + * `undefined` when the body's structure holds. + * + * A renderer over the facts rather than a second walk: what it says is + * unchanged, and what it says it about is decided once, above. + */ +export function renderBodyStructure(facts: BodyStructureFacts): ErrorSegment | undefined { + if (facts.mode === "value") { + const violations: string[] = []; + for (const segment of facts.exclusiveOutputs) { + violations.push(`${previewOutput(segment)} — and \`returns\` are exclusive`); + } + if (facts.missingReturn) { + violations.push("no "); + } + for (const violation of facts.returnViolations) { + violations.push(violation.message); + } + if (violations.length === 0) { + return undefined; + } + return structureError( + "Return", + "A component that declares `returns` renders nothing and produces exactly one " + + "value through a its own body executes. Problems found:", + violations, + ); + } + + const outputError = misplacedOutputAggregate(facts.misplacedOutputs); + const returnError = + facts.undeclaredReturns.length === 0 + ? undefined + : structureError( + "Return", + " requires a document or component that declares `returns`. Declare a " + + "return schema, or remove . Found:", + facts.undeclaredReturns.map(previewReturn), + ); + if (outputError && returnError) { + return { + type: "error", + message: `${outputError.message}\n\n${returnError.message}`, + source: "Return", + }; + } + return outputError ?? returnError; +} + +/** + * Structural preflight (spec §6.9). Validates `` placement against the + * body's own source AST. Only a direct top-level `` is a valid + * declaration; any `` at depth > 0 — including inside unreachable or + * discarded children — is a placement violation. All violations are combined + * into a single aggregate ErrorSegment. Returns undefined when placement is + * valid. + */ +export function validateOutputPlacement(bodySegments: Segment[]): ErrorSegment | undefined { + return misplacedOutputAggregate(collectOutputs(bodySegments, 1)); +} + +/** + * Structural preflight for a body's output and return contract (spec §6.9, + * §6.10). Runs against the body's own source AST, before `` + * substitution, so projected content can neither introduce nor satisfy a + * declaration. Every violation is combined into a single ErrorSegment, and a + * body whose structure is invalid runs no eval, exec, ``, or nested + * component. + */ +export function validateBodyStructure( + bodySegments: Segment[], + returns: ReturnsSchema | undefined, +): ErrorSegment | undefined { + return renderBodyStructure(bodyStructureFacts(bodySegments, returns)); +} + +/** What a misplaced `` reaching expansion's dispatch says. */ +export function misplacedOutputMessage(): string { + return ( + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside ." + ); +} + +/** What a `` written outside a value body's flow says. */ +export function misplacedReturnMessage(segment: ComponentElement): string { + return ( + `${previewReturn(segment)} is not written in the flow of a body that declares ` + + "`returns`, so there is no declaration for it to satisfy. is reserved: " + + "it never resolves a component, and neither markdown produced at runtime nor " + + "another component's body can declare one." + ); +} + +/** What an `` carrying props says, or `undefined` when it carries none. */ +export function outputPropsViolation(segment: ComponentElement): string | undefined { + const hasProps = Object.keys(segment.props).length > 0; + const hasExpressions = Object.keys(segment.expressions).length > 0; + return hasProps || hasExpressions ? " accepts no props." : undefined; +} diff --git a/packages/core/src/components/declared-registry.ts b/packages/core/src/components/declared-registry.ts new file mode 100644 index 000000000..861fd4459 --- /dev/null +++ b/packages/core/src/components/declared-registry.ts @@ -0,0 +1,51 @@ +/** + * The registry a host's identity declarations make, for the paths that describe + * a document rather than run one. + * + * Syntax inspection and document validation both answer for an environment the + * host declared but no execution ever opened. They resolve against the same + * entries, built here once, so the two cannot disagree about what a declared + * name means or about which tier it wins on. + */ + +import type { Operation } from "effection"; + +import { documentationOf } from "./documentation.ts"; +import type { IdentityComponent } from "../invocation-identity.ts"; +import type { ComponentRegistry, FunctionComponentDefinition, RegistryEntry } from "../types.ts"; + +/** + * The identity declarations a host would make, as registry entries selection + * can decide against. + * + * The implementation slot holds a refusal rather than what the factory would + * build, because building it is the authority this has none of: the factory + * takes an execution's claimant, and there is no execution here. Nothing that + * only describes a document reaches an implementation, so the refusal is + * unreachable — it is there so that anything which ever did would fail loudly + * rather than run a component with no execution behind it. + */ +export function declaredRegistry(components: readonly IdentityComponent[]): ComponentRegistry { + const entries = new Map(); + for (const component of components) { + const definition: FunctionComponentDefinition = { + kind: "function", + name: component.name, + props: component.props, + ...(component.returns === undefined ? {} : { returns: component.returns }), + ...(component.captures === undefined ? {} : { captures: component.captures }), + ...(component.forms === undefined ? {} : { forms: component.forms }), + ...documentationOf(component), + fn: uninvocable, + }; + entries.set(component.name, { default: { definition, origin: component.origin } }); + } + return entries; +} + +// deno-lint-ignore require-yield +function* uninvocable(): Operation { + throw new Error( + "this component was described rather than executed, so it has no implementation to run", + ); +} diff --git a/packages/core/src/definition.ts b/packages/core/src/definition.ts index 242b6f1c2..b88af713a 100644 --- a/packages/core/src/definition.ts +++ b/packages/core/src/definition.ts @@ -1,7 +1,8 @@ -import { Ok } from "effection"; +import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import type { ComponentDefinition, Segment } from "./types.ts"; -import { parseFrontmatter } from "./frontmatter.ts"; +import { frontmatterFailure, parseFrontmatterPhased } from "./frontmatter.ts"; +import type { FrontmatterPhase } from "./frontmatter.ts"; import { compilePropsSchema, compileReturnsSchema } from "./validate.ts"; import { scanComponentSpans, scanSegments } from "./scanner.ts"; import { findTarget, outlineDocument, retainedRanges, selectTarget } from "./document-targets.ts"; @@ -81,13 +82,83 @@ interface CompiledFrontmatter { returns: ComponentDefinition["returns"]; } -function* compileFrontmatter(data: Record): Operation { - const { meta, props, returns } = parseFrontmatter(data); - yield* compilePropsSchema(props); +/** + * Which decision a definition failure came from. + * + * Parsing a definition is a fixed sequence — source structure, then the target + * a selector names, then each frontmatter declaration — and every step has its + * own remedy. A caller that reports failures as data rather than raising them + * reads the phase from the step that failed, never from the wording of the + * error the step produced. + */ +export type DefinitionPhase = FrontmatterPhase | "source" | "target"; + +/** + * One definition failure, carrying the decision that produced it. + * + * The original failure travels under `original`, so a caller that raises rather + * than reports raises exactly what it always raised. + */ +export class DefinitionPhaseError extends Error { + readonly phase: DefinitionPhase; + readonly original: unknown; + + constructor(phase: DefinitionPhase, original: unknown) { + super(original instanceof Error ? original.message : String(original), { cause: original }); + this.name = "DefinitionPhaseError"; + this.phase = phase; + this.original = original; + } +} + +function failed(phase: DefinitionPhase, original: unknown): Result { + return Err(new DefinitionPhaseError(phase, original)); +} + +/** + * The phase failure this error is, or the error itself if it is not one. + * + * Only the phased parsers below produce the `Err` side, so anything else + * reaching here is a failure this module did not classify and is raised rather + * than described. + */ +export function definitionFailure(error: Error): DefinitionPhaseError { + if (error instanceof DefinitionPhaseError) { + return error; + } + throw error; +} + +/** Unwrap a phased outcome the way every raising caller always has. */ +function unwrap(outcome: Result): T { + if (!outcome.ok) { + throw definitionFailure(outcome.error).original; + } + return outcome.value; +} + +function* compileFrontmatter( + data: Record, +): Operation> { + const parsed = parseFrontmatterPhased(data); + if (!parsed.ok) { + const failure = frontmatterFailure(parsed.error); + return failed(failure.phase, failure.original); + } + const { meta, props, returns } = parsed.value; + try { + yield* compilePropsSchema(props); + } catch (error) { + return failed("props-declaration", error); + } if (returns !== undefined) { - yield* compileReturnsSchema(returns); + try { + yield* compileReturnsSchema(returns); + } catch (error) { + return failed("returns-declaration", error); + } } - return { meta, props, returns }; + return Ok({ meta, props, returns }); } function buildDefinition( @@ -123,14 +194,44 @@ export function* parseMarkdownDefinition( path: string, content: string, ): Operation { - const body = parseSource(path, content); + return unwrap(yield* parseMarkdownDefinitionPhased(name, path, content)); +} + +/** + * Parse a markdown component definition, reporting a failure as the phase it + * belongs to rather than raising it. + * + * `parseMarkdownDefinition()` is this operation with the failure thrown, so + * there is one parser and one order: a caller reporting failures as data and a + * caller that raises them cannot disagree about what a definition declares or + * about which decision rejected it. + */ +export function* parseMarkdownDefinitionPhased( + name: string, + path: string, + content: string, +): Operation> { + let body: ParsedSource; + try { + body = parseSource(path, content); + } catch (error) { + return failed("source", error); + } const frontmatter = yield* compileFrontmatter(body.data); - return buildDefinition( - name, - path, - frontmatter, - scanSegments(body.content, { path, baseOffset: body.baseOffset, baseLine: body.baseLine }), - ); + if (!frontmatter.ok) { + return frontmatter; + } + let bodySegments: Segment[]; + try { + bodySegments = scanSegments(body.content, { + path, + baseOffset: body.baseOffset, + baseLine: body.baseLine, + }); + } catch (error) { + return failed("source", error); + } + return Ok(buildDefinition(name, path, frontmatter.value, bodySegments)); } /** A root document as parsed: what it declares, and what it addresses. */ @@ -164,47 +265,90 @@ export function* parseRootMarkdownDefinition( content: string, selector?: string, ): Operation { + return unwrap(yield* parseRootMarkdownDefinitionPhased(name, path, content, selector)); +} + +/** + * Parse a root document, reporting a failure as the phase it belongs to rather + * than raising it. + * + * `parseRootMarkdownDefinition()` is this operation with the failure thrown, so + * the order below — syntax, target, schemas, projected definition — is the one + * order every public path has. + */ +export function* parseRootMarkdownDefinitionPhased( + name: string, + path: string, + content: string, + selector?: string, +): Operation> { // The order is the contract, and it is the same on every public path. // Syntax first, because the outline comes from it; then the target, because a // caller who named nothing the document offers asked the wrong question and // should hear that rather than a complaint about a schema they did not reach; // then the schemas; then the projected definition. - const body = parseSource(path, content); - const outline = outlineDocument(body.content, scanComponentSpans(body.content)); + let body: ParsedSource; + let outline: DocumentOutline; + try { + body = parseSource(path, content); + outline = outlineDocument(body.content, scanComponentSpans(body.content)); + } catch (error) { + return failed("source", error); + } if (selector === undefined) { const frontmatter = yield* compileFrontmatter(body.data); - const bodySegments = scanSegments(body.content, { - path, - baseOffset: body.baseOffset, - baseLine: body.baseLine, - }); - return { - definition: buildDefinition(name, path, frontmatter, bodySegments), + if (!frontmatter.ok) { + return frontmatter; + } + let bodySegments: Segment[]; + try { + bodySegments = scanSegments(body.content, { + path, + baseOffset: body.baseOffset, + baseLine: body.baseLine, + }); + } catch (error) { + return failed("source", error); + } + return Ok({ + definition: buildDefinition(name, path, frontmatter.value, bodySegments), targets: outline.targets, targetInfo: outline.targetInfo, - }; + }); } - const entry = selectTarget(outline, selector); + let entry: ReturnType; + try { + entry = selectTarget(outline, selector); + } catch (error) { + return failed("target", error); + } const frontmatter = yield* compileFrontmatter(body.data); - const newlines = newlineCounts(body.content); + if (!frontmatter.ok) { + return frontmatter; + } const bodySegments: Segment[] = []; - for (const range of retainedRanges(outline, entry)) { - bodySegments.push( - ...scanSegments(body.content.slice(range.start, range.end), { - path, - baseOffset: body.baseOffset + range.start, - baseLine: body.baseLine + newlines[range.start]!, - }), - ); + try { + const newlines = newlineCounts(body.content); + for (const range of retainedRanges(outline, entry)) { + bodySegments.push( + ...scanSegments(body.content.slice(range.start, range.end), { + path, + baseOffset: body.baseOffset + range.start, + baseLine: body.baseLine + newlines[range.start]!, + }), + ); + } + } catch (error) { + return failed("source", error); } - return { - definition: buildDefinition(name, path, frontmatter, bodySegments), + return Ok({ + definition: buildDefinition(name, path, frontmatter.value, bodySegments), targets: outline.targets, targetInfo: outline.targetInfo, target: entry.target, - }; + }); } /** How many newlines precede each offset, so a retained range knows its line. */ diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts new file mode 100644 index 000000000..a864bd3f6 --- /dev/null +++ b/packages/core/src/document-validation.ts @@ -0,0 +1,1119 @@ +/** + * Validating a supplied document without executing any of it. + * + * A host that generated a program, or received one, needs to know whether it is + * a program at all before it asks a person to approve it. That question is + * answerable: component resolution, declaration admission, authored forms, body + * shape, structural placement and prop schemas are all decided from source and + * from what the host declared, never from a value the document computes. + * + * So this reads. It applies ordinary target selection to the supplied root, + * scans that projection with the scanner execution uses, and follows ordinary + * component selection through every Markdown definition the authored + * invocations discover. It evaluates no expression, invokes no component, + * imports no repository module, projects no content, calls no identity factory, + * installs no provider, opens no journal, and performs no filesystem operation + * the document asked for. The only bytes it reads are the root and the Markdown + * definitions selection identified — the same reads inspection already makes. + * + * The answer is versioned data rather than a thrown exception or rendered + * prose: one closed code per diagnostic, the normalized schema issues execution + * already produces, and one record per authored invocation saying whether it is + * valid, invalid, or not decidable without running the document. A generator or + * a person reads the codes; nobody parses the messages. + */ + +import type { Operation } from "effection"; +import { readTextFile } from "@executablemd/runtime"; + +import { + bodyStructureFacts, + outputPropsViolation, + previewOutput, + previewReturn, +} from "./body-structure.ts"; +import type { BodyStructureFacts } from "./body-structure.ts"; +import { Component } from "./component-api.ts"; +import { declaredRegistry } from "./components/declared-registry.ts"; +import { admitDeclaration, mergeRegistry } from "./components/registration.ts"; +import { DEFAULT_INCLUDES, selectComponent, unresolvedMessage } from "./components/select.ts"; +import { + definitionFailure, + isFunctionComponentPath, + parseMarkdownDefinitionPhased, + parseRootMarkdownDefinitionPhased, +} from "./definition.ts"; +import type { DefinitionPhase } from "./definition.ts"; +import { assertDistinctIdentityNames } from "./invocation-identity.ts"; +import type { IdentityComponent } from "./invocation-identity.ts"; +import { validateBindingName } from "./live-env.ts"; +import { readRootSource, rootSourcePath } from "./root-source.ts"; +import type { RootDocumentSource } from "./root-source.ts"; +import { + answersViolations, + answerViolations, + breakViolations, + eachViolations, + ifConditionViolation, + ifPropsViolation, + ifStructure, + letViolations, + loopViolations, + printErrorsViolations, + strayAnswerMessage, + strayElseMessage, + strayStructuralMessage, +} from "./structural-rules.ts"; +import type { StructuralViolation } from "./structural-rules.ts"; +import type { + ComponentDefinition, + ComponentElement, + ComponentOrigin, + ComponentRegistry, + InvocationForm, + Json, + PropsSchema, + Segment, + SourcePosition, +} from "./types.ts"; +import { PropValidationError, SchemaValidationError, validateProps } from "./validate.ts"; +import type { NormalizedIssue } from "./validate.ts"; + +/** + * What the host supplies beside the root itself. + * + * The same declarative environment `inspectSyntax()` reads, and no more: root + * props as plain JSON, the ordered include path, and the identity components a + * host would declare to an execution. There is no `cwd` option because there is + * no second working directory — a relative root or include resolves against the + * contextual runtime's, exactly as execution resolves it. + */ +export interface ValidateDocumentSettings { + /** The props a run would supply. Absent is `{}`. */ + readonly props?: Record; + /** Where components are looked for. Absent is `DEFAULT_INCLUDES`. */ + readonly includes?: readonly string[]; + /** + * Identity components the host would declare to an execution, with the same + * meaning `ExecuteOptions.components` and `InspectSyntaxOptions.components` + * give them — admissibility included. + * + * A set an execution would refuse is refused here too, as a thrown + * configuration error rather than a document diagnostic: a document is not + * invalid because the host that asked about it described an environment no + * document could run in. The factory is never called. + */ + readonly components?: readonly IdentityComponent[]; +} + +/** The root to validate, and the environment to validate it against. */ +export type ValidateDocumentOptions = RootDocumentSource & ValidateDocumentSettings; + +/** Where one authored invocation was written. */ +export interface InvocationSite { + readonly name: string; + readonly position?: Readonly; +} + +/** Why an invocation could not be decided without running the document. */ +export type InvocationOpacityReason = "dynamic-props" | "origin-only-contract"; + +/** + * What validation concluded about one authored invocation. + * + * `valid` and `not-statically-checkable` both carry the origin selection chose, + * because both of them resolved. `invalid` carries one only when it did: an + * unresolved name has no origin to report, while a component whose source later + * failed to parse keeps the origin it resolved to. + */ +export type InvocationValidation = InvocationSite & + ( + | { + readonly outcome: "valid"; + readonly origin: Readonly; + } + | { + readonly outcome: "invalid"; + readonly origin?: Readonly; + readonly diagnosticIndexes: readonly number[]; + } + | { + readonly outcome: "not-statically-checkable"; + readonly origin: Readonly; + readonly reasons: readonly InvocationOpacityReason[]; + } + ); + +/** + * Every condition version 1 can establish before execution. + * + * Closed, and ordered: this is the order two diagnostics at one position sort + * in, so the union's shape is part of the answer rather than a listing of it. + */ +export type DocumentValidationCode = + | "source-unreadable" + | "source-invalid" + | "target-invalid" + | "frontmatter-invalid" + | "props-declaration-invalid" + | "returns-declaration-invalid" + | "component-unresolved" + | "component-ambiguous" + | "invocation-form-invalid" + | "body-shape-invalid" + | "props-invalid" + | "binding-invalid" + | "capture-invalid" + | "return-usage-invalid" + | "structural-usage-invalid"; + +/** One definite failure, in the source it belongs to. */ +export interface DocumentValidationDiagnostic { + readonly code: DocumentValidationCode; + readonly message: string; + readonly position?: Readonly; + readonly component?: string; + /** The normalized schema issues, when a schema is what failed. */ + readonly issues?: readonly NormalizedIssue[]; +} + +/** What validating one document concluded. */ +export interface DocumentValidation { + readonly version: 1; + readonly outcome: "valid" | "invalid"; + readonly diagnostics: readonly DocumentValidationDiagnostic[]; + readonly invocations: readonly InvocationValidation[]; +} + +/** The order two diagnostics at one position are reported in. */ +const CODE_ORDER: readonly DocumentValidationCode[] = [ + "source-unreadable", + "source-invalid", + "target-invalid", + "frontmatter-invalid", + "props-declaration-invalid", + "returns-declaration-invalid", + "component-unresolved", + "component-ambiguous", + "invocation-form-invalid", + "body-shape-invalid", + "props-invalid", + "binding-invalid", + "capture-invalid", + "return-usage-invalid", + "structural-usage-invalid", +]; + +/** + * The rank a code sorts at, exported so a consumer ordering diagnostics of its + * own reaches the same order this does rather than a second one. + */ +export function documentValidationCodeRank(code: DocumentValidationCode): number { + return CODE_ORDER.indexOf(code); +} + +/** + * The forms a component accepts when it declares none: both of them. + * + * Omission is what every registration meant before forms could be declared, and + * it is what a Markdown component means — a Markdown definition renders its + * content where `` appears and renders without it either way. + */ +const BOTH_FORMS: readonly InvocationForm[] = ["self-closing", "paired"]; + +/** The name root-props validation reports itself under, as execution does. */ +const ROOT_NAME = "__root__"; + +/** + * One diagnostic while the walk is still running. + * + * The public index does not exist yet — it cannot, because it is an index into + * an array that is only sorted once the walk ends. A draft therefore carries a + * token an invocation refers to, and the sorter rewrites tokens into indexes. + */ +interface DraftDiagnostic { + readonly token: number; + readonly sourceOrdinal: number; + readonly sequence: number; + readonly code: DocumentValidationCode; + readonly message: string; + readonly position?: Readonly; + readonly component?: string; + readonly issues?: readonly NormalizedIssue[]; + /** + * The failure a read or a parser produced, kept on the draft and never + * published. The answer carries stable prose; an errno object, a stack, or a + * host path from outside the workspace is not something a versioned + * diagnostic hands to whoever reads it next. + */ + readonly cause?: unknown; +} + +/** One invocation record while the walk is still running. */ +interface DraftInvocation { + readonly name: string; + readonly position?: Readonly; + origin?: Readonly; + readonly tokens: number[]; + readonly reasons: InvocationOpacityReason[]; +} + +/** A source that parsed, with everything its own declaration decided. */ +interface ParsedSourceEntry { + readonly state: "parsed"; + readonly ordinal: number; + readonly definition: ComponentDefinition; + /** The body-contract diagnostics this source's own declaration produced. */ + readonly bodyTokens: readonly number[]; + /** Which of those belong to one authored element. */ + readonly elementTokens: ReadonlyMap; +} + +/** A source that could not be read or parsed, and the one failure that says so. */ +interface FailedSourceEntry { + readonly state: "failed"; + readonly ordinal: number; + readonly token: number; +} + +type SourceEntry = ParsedSourceEntry | FailedSourceEntry; + +/** Where in a source's own structure a walk currently is. */ +interface LexicalContext { + /** The source being walked. */ + readonly entry: ParsedSourceEntry; + /** Whether this is the supplied root rather than a selected definition. */ + readonly isRoot: boolean; + /** Whether a `` in this source lexically encloses this point. */ + readonly insideLoop: boolean; + /** Whether an `` in this source lexically encloses this point. */ + readonly insideIf: boolean; + /** Whether the immediate parent is an ``. */ + readonly underAnswers: boolean; +} + +/** What a complete contract states about one invocation. */ +interface CompleteContract { + readonly props: PropsSchema; + readonly captures: readonly string[]; + readonly forms: readonly InvocationForm[]; + readonly hasReturns: boolean; +} + +/** + * Validate a supplied document, and every Markdown definition its authored + * invocations reach, without executing any of it. + * + * Traversal follows source rather than execution: the selected root projection + * first, then each discovered definition in the order its first invocation was + * encountered, each source scanned exactly once. An invocation written inside + * ``, `` or `` is authored program structure and is checked + * like any other; no branch is evaluated to decide whether it would run. A + * definition that invokes itself, directly or through others, terminates the + * walk when the source identity comes back around, and that is not a failure. + * + * The result is deterministic: the same document and environment produce + * deep-equal diagnostics and invocation records every time. + */ +export function* validateDocument(options: ValidateDocumentOptions): Operation { + const includes = options.includes ?? DEFAULT_INCLUDES; + const declared = options.components ?? []; + // Admitted before anything is read, on exactly the terms ordinary execution + // admits declarations on. A set an execution would refuse describes an + // environment no document could run in, so it is the caller's error rather + // than the document's, and it escapes instead of becoming a diagnostic. + assertDistinctIdentityNames(declared); + for (const component of declared) { + yield* admitDeclaration(component); + } + const registry = mergeRegistry(yield* Component.operations.registry, declaredRegistry(declared)); + + const state = new ValidationState(includes, registry); + yield* state.run(options); + return state.finish(); +} + +/** + * The one walk, and everything it accumulates. + * + * A class rather than a bag of parameters because every step writes into the + * same three places — the diagnostic drafts, the invocation drafts, and the + * source cache — and because the sorter at the end has to see all three + * together to rewrite tokens into indexes. + */ +class ValidationState { + readonly #includes: readonly string[]; + readonly #registry: ComponentRegistry; + readonly #diagnostics: DraftDiagnostic[] = []; + readonly #invocations: DraftInvocation[] = []; + /** Source identity to what reading it produced. Read once, reused always. */ + readonly #sources = new Map(); + /** Parsed sources still waiting to have their own body walked. */ + readonly #queue: ParsedSourceEntry[] = []; + #nextOrdinal = 0; + #nextToken = 0; + #sequence = 0; + + constructor(includes: readonly string[], registry: ComponentRegistry) { + this.#includes = includes; + this.#registry = registry; + } + + *run(options: ValidateDocumentOptions): Operation { + const root = yield* this.#readRoot(options); + if (root === undefined) { + return; + } + + // Root props are supplied JSON rather than document expressions, so the + // whole object is validated: there is no dynamic value here to be opaque + // about. A failure belongs to the root itself and invents no invocation. + yield* this.#validateRootProps(root, options.props ?? {}); + + this.#queue.push(root); + while (this.#queue.length > 0) { + const entry = this.#queue.shift()!; + yield* this.#walk(entry.definition.bodySegments, { + entry, + isRoot: entry.ordinal === 0, + insideLoop: false, + insideIf: false, + underAnswers: false, + }); + } + } + + /** The root, read and parsed, or `undefined` when it failed to become one. */ + *#readRoot(options: ValidateDocumentOptions): Operation { + const ordinal = this.#nextOrdinal++; + const path = rootSourcePath(options); + if (isFunctionComponentPath(path)) { + // A `.ts` root is a module execution would import, not a document. There + // is no markdown to scan and no frontmatter to read, so the source itself + // is what is wrong with it. + this.#draft(ordinal, "source-invalid", { + message: "Root document must be a markdown file, not a function component", + }); + return undefined; + } + + let content: string; + try { + content = yield* readRootSource(options); + } catch (error) { + this.#draft(ordinal, "source-unreadable", { message: unreadable(path), cause: error }); + return undefined; + } + + const parsed = yield* parseRootMarkdownDefinitionPhased( + ROOT_NAME, + path, + content, + options.target, + ); + if (!parsed.ok) { + const failure = definitionFailure(parsed.error); + this.#draft(ordinal, codeForPhase(failure.phase), { + message: messageOf(failure.original), + cause: failure.original, + }); + return undefined; + } + return this.#admitParsed(ordinal, parsed.value.definition); + } + + /** + * The cache entry for a selected Markdown definition, reading and parsing it + * on its first invocation and reusing that one answer afterwards. + * + * The canonical selected path is the identity, so two names selecting one + * file share the entry, a definition that invokes itself finds itself here + * rather than recursing, and a file that could not be read carries one + * failure however many invocations selected it. + */ + *#loadSource(name: string, path: string): Operation { + const cached = this.#sources.get(path); + if (cached !== undefined) { + return cached; + } + const ordinal = this.#nextOrdinal++; + + let content: string; + try { + content = yield* readTextFile(path); + } catch (error) { + return this.#failSource(path, ordinal, "source-unreadable", unreadable(path), name, error); + } + + const parsed = yield* parseMarkdownDefinitionPhased(name, path, content); + if (!parsed.ok) { + const failure = definitionFailure(parsed.error); + return this.#failSource( + path, + ordinal, + codeForPhase(failure.phase), + `${path}: ${messageOf(failure.original)}`, + name, + failure.original, + ); + } + + const entry = this.#admitParsed(ordinal, parsed.value, path); + this.#queue.push(entry); + return entry; + } + + #failSource( + path: string, + ordinal: number, + code: DocumentValidationCode, + message: string, + component: string, + cause?: unknown, + ): FailedSourceEntry { + // No position: nothing inside the file was reached, so there is no authored + // place to point at. The file names itself in the message instead. + const token = this.#draft(ordinal, code, { message, component, cause }); + const entry: FailedSourceEntry = { state: "failed", ordinal, token }; + this.#sources.set(path, entry); + return entry; + } + + /** + * Record a parsed source, together with what its own body contract decided. + * + * The body contract is read here rather than during the walk because an + * invocation of this definition may be checked long before this source's turn + * in the queue comes up, and it has to be able to point at the same + * diagnostics every other caller points at. + */ + #admitParsed( + ordinal: number, + definition: ComponentDefinition, + identity?: string, + ): ParsedSourceEntry { + const facts = bodyStructureFacts(definition.bodySegments, definition.returns); + const elementTokens = new Map(); + const bodyTokens: number[] = []; + const owner = definition.name === ROOT_NAME ? undefined : definition.name; + for (const drafted of this.#draftBodyFacts(ordinal, facts, owner)) { + bodyTokens.push(drafted.token); + if (drafted.element === undefined) { + continue; + } + const existing = elementTokens.get(drafted.element); + if (existing === undefined) { + elementTokens.set(drafted.element, [drafted.token]); + } else { + existing.push(drafted.token); + } + } + const entry: ParsedSourceEntry = { + state: "parsed", + ordinal, + definition, + bodyTokens, + elementTokens, + }; + if (identity !== undefined) { + this.#sources.set(identity, entry); + } + return entry; + } + + /** + * One body-contract fact per diagnostic, at the position it was authored. + * + * Expansion renders the same facts as one aggregate printed error, which is + * the right shape for a reader. A consumer acting on them needs each + * violation separately, under its own code, so an `` in the wrong + * place and a `` with no declaration do not arrive as one sentence. + */ + #draftBodyFacts( + ordinal: number, + facts: BodyStructureFacts, + owner: string | undefined, + ): { token: number; element?: ComponentElement }[] { + const drafted: { token: number; element?: ComponentElement }[] = []; + for (const element of facts.misplacedOutputs) { + drafted.push({ + element, + token: this.#draft(ordinal, "body-shape-invalid", { + message: + " must be a direct top-level child of the component or document that " + + `declares it. Found ${previewOutput(element)}.`, + component: "Output", + ...positionOf(element), + }), + }); + } + for (const element of facts.exclusiveOutputs) { + drafted.push({ + element, + token: this.#draft(ordinal, "body-shape-invalid", { + message: `${previewOutput(element)} — and \`returns\` are exclusive.`, + component: "Output", + ...positionOf(element), + }), + }); + } + for (const element of facts.undeclaredReturns) { + drafted.push({ + element, + token: this.#draft(ordinal, "return-usage-invalid", { + message: + `${previewReturn(element)} requires a document or component that declares ` + + "`returns`. Declare a return schema, or remove .", + component: "Return", + ...positionOf(element), + }), + }); + } + if (facts.missingReturn) { + drafted.push({ + token: this.#draft(ordinal, "return-usage-invalid", { + message: + "A component that declares `returns` renders nothing and produces exactly one " + + "value through a its own body executes. This body writes no .", + ...(owner === undefined ? {} : { component: owner }), + }), + }); + } + for (const violation of facts.returnViolations) { + drafted.push({ + element: violation.element, + token: this.#draft(ordinal, "return-usage-invalid", { + message: `${violation.message}.`, + component: "Return", + ...positionOf(violation.element), + }), + }); + } + return drafted; + } + + *#validateRootProps(root: ParsedSourceEntry, props: Record): Operation { + try { + yield* validateProps(ROOT_NAME, props, root.definition.props); + } catch (error) { + if (error instanceof PropValidationError) { + this.#draft(root.ordinal, "props-invalid", { + message: error.message, + issues: error.issues, + }); + return; + } + throw error; + } + } + + *#walk(segments: readonly Segment[], context: LexicalContext): Operation { + for (const segment of segments) { + if (segment.type === "codeBlock") { + // The block is never compiled or run. Its `as=` annotation is authored + // structure the scanner already decided about, so a refused one is a + // definite failure of the source rather than of a process. + const binding = segment.binding; + if (binding !== undefined && !binding.ok) { + this.#draft(context.entry.ordinal, "binding-invalid", { + message: binding.error.message, + ...(segment.position === undefined ? {} : { position: segment.position }), + }); + } + continue; + } + if (segment.type !== "component") { + continue; + } + yield* this.#visit(segment, context); + yield* this.#walk(segment.children, childContext(segment, context)); + } + } + + *#visit(segment: ComponentElement, context: LexicalContext): Operation { + const draft: DraftInvocation = { + name: segment.name, + ...(segment.position === undefined ? {} : { position: segment.position }), + tokens: [...(context.entry.elementTokens.get(segment) ?? [])], + reasons: [], + }; + this.#invocations.push(draft); + + const selected = yield* selectComponent(segment.name, { + includes: this.#includes, + registry: this.#registry, + }); + + if (selected.kind === "structural") { + draft.origin = { kind: "structural", construct: selected.construct }; + for (const violation of this.#structuralViolations(segment, context)) { + draft.tokens.push( + this.#draft(context.entry.ordinal, violation.code, { + message: violation.message, + component: segment.name, + ...positionOf(violation.element ?? segment), + }), + ); + } + return; + } + + if (selected.kind === "unresolved") { + // No origin: nothing was selected, so there is nothing to report having + // been selected. That is the one invalid outcome with no origin at all. + draft.tokens.push( + this.#draft(context.entry.ordinal, "component-unresolved", { + message: unresolvedMessage(segment.name, selected.searched), + component: segment.name, + ...positionOf(segment), + }), + ); + return; + } + + if (selected.kind === "workflow") { + // A component bundle is authority one document execution runs under. + // Validation installs none, so this tier answers for no validation. + throw new Error( + `Component ${segment.name} resolved through a workflow component bundle, which ` + + "describes a document execution rather than a document.", + ); + } + + // Engine-owned and independent of any contract: `as` names a binding, and + // whether the author wrote a name at all is decided from the syntax. + const captureViolation = componentCaptureViolation(segment); + if (captureViolation !== undefined) { + draft.tokens.push( + this.#draft(context.entry.ordinal, "capture-invalid", { + message: captureViolation, + component: segment.name, + ...positionOf(segment), + }), + ); + } + + if (selected.kind === "registered") { + draft.origin = selected.origin; + yield* this.#checkContract(segment, context, draft, { + props: selected.definition.props, + captures: selected.definition.captures ?? [], + forms: selected.definition.forms ?? BOTH_FORMS, + hasReturns: selected.definition.returns !== undefined, + }); + return; + } + + const origin: ComponentOrigin = { kind: "repository", path: selected.path }; + draft.origin = origin; + + if (isFunctionComponentPath(selected.path)) { + // Its contract lives on the module's exports, and reading them would run + // the module's top-level code. Nothing is assumed in its place: no forms, + // no props schema, no captures, no return mode. Only the engine-owned + // checks above applied, and what is left is unknown rather than accepted. + if (draft.tokens.length === 0) { + if (hasDynamicProp(segment, [])) { + draft.reasons.push("dynamic-props"); + } + draft.reasons.push("origin-only-contract"); + } + return; + } + + const source = yield* this.#loadSource(segment.name, selected.path); + if (source.state === "failed") { + // Every invocation that selected this definition points at the one + // failure the source has, and none of them invents a second. + draft.tokens.push(source.token); + return; + } + // A definition whose own body contract is broken is broken for every + // caller, at the position its own source states. + draft.tokens.push(...source.bodyTokens); + yield* this.#checkContract(segment, context, draft, { + props: source.definition.props, + captures: [], + forms: BOTH_FORMS, + hasReturns: source.definition.returns !== undefined, + }); + } + + /** + * The checks a complete contract makes possible: the authored form, the + * return-mode requirement, and the props schema. + */ + *#checkContract( + segment: ComponentElement, + context: LexicalContext, + draft: DraftInvocation, + contract: CompleteContract, + ): Operation { + const ordinal = context.entry.ordinal; + const form: InvocationForm = segment.selfClosing ? "self-closing" : "paired"; + if (!contract.forms.includes(form)) { + draft.tokens.push( + this.#draft(ordinal, "invocation-form-invalid", { + message: + `<${segment.name} /> is not written in a form it accepts: it was invoked ` + + `${form}, and it accepts ${contract.forms.join(" and ")}.`, + component: segment.name, + ...positionOf(segment), + }), + ); + } + + if (contract.hasReturns && !("as" in segment.props) && !("as" in segment.expressions)) { + draft.tokens.push( + this.#draft(ordinal, "return-usage-invalid", { + message: + `<${segment.name} /> declares \`returns\`, so it renders nothing and must be ` + + `invoked with \`as\`: <${segment.name} as="binding" />.`, + component: segment.name, + ...positionOf(segment), + }), + ); + } + + // `slot` and `as` are the engine's, and a declared capture is handed over + // unresolved — none of the three meets the schema, during execution or here. + const statics = schemaVisible(segment.props, contract.captures); + const dynamics = schemaVisible(segment.expressions, contract.captures); + + if (Object.keys(dynamics).length > 0) { + // A required key is absent or it is not, and no value is needed to say + // which — so this stays definite even beside a prop nothing can resolve. + const missing = missingRequired(contract.props, statics, dynamics); + if (missing.length > 0) { + const error = new SchemaValidationError( + segment.name, + `Prop validation failed for <${segment.name} />:`, + missing, + ); + draft.tokens.push( + this.#draft(ordinal, "props-invalid", { + message: error.message, + component: segment.name, + issues: missing, + ...positionOf(segment), + }), + ); + } + // Ajv is not asked a question about an object it has not been given. + // Partially solving a schema across the values that are there and the + // ones that are not would answer with conclusions nothing established. + draft.reasons.push("dynamic-props"); + return; + } + + try { + yield* validateProps(segment.name, statics, contract.props); + } catch (error) { + if (error instanceof PropValidationError) { + draft.tokens.push( + this.#draft(ordinal, "props-invalid", { + message: error.message, + component: segment.name, + issues: error.issues, + ...positionOf(segment), + }), + ); + return; + } + throw error; + } + } + + /** Everything one structural construct's own source decided. */ + #structuralViolations( + segment: ComponentElement, + context: LexicalContext, + ): readonly StructuralViolation[] { + switch (segment.name) { + case "Let": + return letViolations(segment); + case "Each": + return eachViolations(segment); + case "If": { + const found: StructuralViolation[] = []; + const unknownProp = ifPropsViolation(segment); + if (unknownProp !== undefined) { + // Expansion stops here too: an `` whose props are wrong expands + // neither branch, and reads no structure it would then complain about. + return [unknownProp]; + } + found.push(...ifStructure(segment).violations); + const condition = ifConditionViolation(segment); + if (condition !== undefined) { + found.push(condition); + } + return found; + } + case "Loop": + return loopViolations(segment); + case "Break": + return breakViolations(segment, context.insideLoop); + case "PrintErrors": + return printErrorsViolations(segment); + case "Answers": + return answersViolations(segment); + case "Answer": + return context.underAnswers + ? answerViolations(segment) + : [ + { + code: "structural-usage-invalid", + source: "Answer", + message: strayAnswerMessage(), + }, + ]; + case "Else": + // A well-placed `` is its ``'s, and one placed wrongly under + // an `` is already reported by that ``'s own structure. What is + // left is an `` with no `` above it at all. + return context.insideIf + ? [] + : [{ code: "structural-usage-invalid", source: "Else", message: strayElseMessage() }]; + case "Output": { + const propsViolation = outputPropsViolation(segment); + return propsViolation === undefined + ? [] + : [{ code: "structural-usage-invalid", source: "Output", message: propsViolation }]; + } + case "Content": + // A component's body projects what its invocation was given. A root + // document is nobody's invocation, so there is nothing to project. + return context.isRoot + ? [ + { + code: "structural-usage-invalid", + source: "Content", + message: strayStructuralMessage("Content"), + }, + ] + : []; + default: + // `` is the remaining case, and its whole contract belongs to + // the body that declares — or fails to declare — `returns`, which the + // source's own facts already stated. + return []; + } + } + + #draft( + sourceOrdinal: number, + code: DocumentValidationCode, + detail: { + message: string; + position?: Readonly; + component?: string; + issues?: readonly NormalizedIssue[]; + cause?: unknown; + }, + ): number { + const token = this.#nextToken++; + this.#diagnostics.push({ + token, + sourceOrdinal, + sequence: this.#sequence++, + code, + message: detail.message, + ...(detail.position === undefined ? {} : { position: detail.position }), + ...(detail.component === undefined ? {} : { component: detail.component }), + ...(detail.issues === undefined ? {} : { issues: detail.issues }), + ...(detail.cause === undefined ? {} : { cause: detail.cause }), + }); + return token; + } + + /** + * The finished answer. + * + * Ordering is decided once, here, and never during the walk: the root first, + * then definitions in the order their first invocation discovered them; a + * source's own unpositioned failure before anything positioned inside it; + * then authored offset; then the closed code order; then discovery, which is + * the stable tie breaker and is never public. + */ + finish(): DocumentValidation { + const sorted = this.#diagnostics.toSorted(compareDrafts); + const indexes = new Map(); + sorted.forEach((draft, index) => indexes.set(draft.token, index)); + + const diagnostics: DocumentValidationDiagnostic[] = sorted.map((draft) => ({ + code: draft.code, + message: draft.message, + ...(draft.position === undefined ? {} : { position: draft.position }), + ...(draft.component === undefined ? {} : { component: draft.component }), + ...(draft.issues === undefined ? {} : { issues: draft.issues }), + })); + + const invocations: InvocationValidation[] = this.#invocations.map((draft) => { + const site: InvocationSite = { + name: draft.name, + ...(draft.position === undefined ? {} : { position: draft.position }), + }; + if (draft.tokens.length > 0) { + const diagnosticIndexes = [ + ...new Set(draft.tokens.map((token) => indexes.get(token)!)), + ].toSorted((left, right) => left - right); + return { + ...site, + outcome: "invalid", + ...(draft.origin === undefined ? {} : { origin: draft.origin }), + diagnosticIndexes, + }; + } + // Both remaining outcomes resolved, so both report an origin. A record + // reaching here without one would mean selection answered and nothing + // wrote down what it answered. + const origin = draft.origin!; + if (draft.reasons.length > 0) { + return { ...site, outcome: "not-statically-checkable", origin, reasons: draft.reasons }; + } + return { ...site, outcome: "valid", origin }; + }); + + return { + version: 1, + outcome: diagnostics.length === 0 ? "valid" : "invalid", + diagnostics, + invocations, + }; + } +} + +function compareDrafts(left: DraftDiagnostic, right: DraftDiagnostic): number { + if (left.sourceOrdinal !== right.sourceOrdinal) { + return left.sourceOrdinal - right.sourceOrdinal; + } + const leftPositioned = left.position === undefined ? 0 : 1; + const rightPositioned = right.position === undefined ? 0 : 1; + if (leftPositioned !== rightPositioned) { + return leftPositioned - rightPositioned; + } + if (left.position !== undefined && right.position !== undefined) { + if (left.position.offset !== right.position.offset) { + return left.position.offset - right.position.offset; + } + } + const rank = documentValidationCodeRank(left.code) - documentValidationCodeRank(right.code); + if (rank !== 0) { + return rank; + } + return left.sequence - right.sequence; +} + +/** The lexical facts one element's children are written under. */ +function childContext(segment: ComponentElement, context: LexicalContext): LexicalContext { + return { + entry: context.entry, + isRoot: context.isRoot, + insideLoop: context.insideLoop || segment.name === "Loop", + insideIf: context.insideIf || segment.name === "If", + underAnswers: segment.name === "Answers", + }; +} + +function positionOf(segment: ComponentElement): { position?: Readonly } { + return segment.position === undefined ? {} : { position: segment.position }; +} + +/** + * The props a schema may see: everything but the engine's own two and whatever + * the selected definition declared a capture. + */ +function schemaVisible( + written: Record, + captures: readonly string[], +): Record { + const captured = new Set(captures); + const visible: Record = {}; + for (const [key, value] of Object.entries(written)) { + if (key === "slot" || key === "as" || captured.has(key)) { + continue; + } + visible[key] = value; + } + return visible; +} + +/** Whether any schema-visible prop is written as an expression. */ +function hasDynamicProp(segment: ComponentElement, captures: readonly string[]): boolean { + return Object.keys(schemaVisible(segment.expressions, captures)).length > 0; +} + +/** + * Required properties whose key is written nowhere at all. + * + * Presence, not value: this asks only whether the author wrote the key, so it + * stays definite beside a prop whose value nothing here can resolve. Every + * other schema conclusion — type, additional properties, conditionals, + * dependencies — is left to the one full-schema call, which runs only when + * every schema-visible value is there. + */ +function missingRequired( + schema: PropsSchema, + statics: Record, + dynamics: Record, +): NormalizedIssue[] { + const required = schema["required"]; + if (!Array.isArray(required)) { + return []; + } + const issues: NormalizedIssue[] = []; + for (const name of required) { + if (typeof name !== "string" || name in statics || name in dynamics) { + continue; + } + issues.push({ + instancePath: "", + schemaPath: "#/required", + keyword: "required", + params: { missingProperty: name }, + message: `must have required property '${name}'`, + }); + } + return issues; +} + +/** + * What an `as` that cannot name a binding says, or `undefined` when it can. + * + * Decided on the authored text rather than a resolved value, exactly as + * expansion decides it: evaluating it first would make the outcome depend on + * the host, because a bare identifier that happens to name a global resolves on + * one runtime and throws on another. + */ +function componentCaptureViolation(segment: ComponentElement): string | undefined { + if ("as" in segment.expressions) { + return `Prop "as" on <${segment.name} /> must be a string literal.`; + } + const binding = validateBindingName(segment.props.as); + return binding.ok ? undefined : `Prop "as" on <${segment.name} /> ${binding.error.message}`; +} + +/** The code one parsing phase's failure is reported under. */ +function codeForPhase(phase: DefinitionPhase): DocumentValidationCode { + switch (phase) { + case "source": + return "source-invalid"; + case "target": + return "target-invalid"; + case "frontmatter": + return "frontmatter-invalid"; + case "props-declaration": + return "props-declaration-invalid"; + case "returns-declaration": + return "returns-declaration-invalid"; + } +} + +/** What a source that could not be read says. */ +function unreadable(path: string): string { + return `Cannot read document source: ${path}`; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index c6a5314ae..f370fdb18 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,12 +13,11 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; +import { ensure, Err, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import type { FunctionComponent, Segment, - TextSegment, ErrorSegment, ComponentElement, ComponentDefinition, @@ -31,6 +30,34 @@ import type { ReturnsSchema, SourcePosition, } from "./types.ts"; +import { + bodyHasOutput, + misplacedOutputMessage, + misplacedReturnMessage, + outputPropsViolation, + validateBodyStructure, + validateOutputPlacement, +} from "./body-structure.ts"; +import { + breakElementViolations, + eachCaptureBinding, + eachItemBinding, + eachItemsViolation, + eachViolations, + ifConditionViolation, + ifPropsViolation, + ifStructure, + letBindingName, + letViolations, + loopBound, + loopMissingBoundMessage, + loopPropsViolation, + printErrorsViolations, + strayBreakMessage, + strayElseMessage, + strayStructuralMessage, +} from "./structural-rules.ts"; +import type { StructuralViolation } from "./structural-rules.ts"; import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; import { @@ -832,7 +859,9 @@ function* expandListSegments( // reaches here. Reaching this branch means a misplaced or // dynamically scanned (e.g. render(markdown) content) — // diagnose it defensively per the ambient error mode. - result.push(yield* raise(misplacedOutputError())); + result.push( + yield* raise({ type: "error", message: misplacedOutputMessage(), source: "Output" }), + ); break; } @@ -851,7 +880,13 @@ function* expandListSegments( // reachable only by the engine that passed it: nothing is published // for a document to read, replace, or hand to an exported helper. if (returnBody === undefined) { - result.push(yield* raise(misplacedReturnError(segment))); + result.push( + yield* raise({ + type: "error", + message: misplacedReturnMessage(segment), + source: "Return", + }), + ); break; } const declared = returnBody.claim(); @@ -925,7 +960,13 @@ function* expandListSegments( // its own. Reaching this branch means the element sits outside any // , so it names no component and is diagnosed rather than // resolved from the filesystem. - result.push(yield* raise(strayElseError(segment))); + result.push( + yield* raise({ + type: "error", + message: positioned(strayElseMessage(), segment), + source: "Else", + }), + ); break; } @@ -1024,7 +1065,13 @@ function* expandListSegments( // one was written where the construct that gives it meaning is not. // It is reserved, so resolution stops rather than looking for a file // that could stand in for the syntax. - result.push(yield* raise(strayStructuralError(segment))); + result.push( + yield* raise({ + type: "error", + message: positioned(strayStructuralMessage(segment.name), segment), + source: segment.name, + }), + ); break; } @@ -1328,8 +1375,6 @@ function letError(message: string): ErrorSegment { return { type: "error", message, source: "Let" }; } -const LET_PROPS = new Set(["as", "value", "select"]); - /** * Bind rendered content or a direct value into `as` (spec §6.5 ``). * @@ -1360,66 +1405,17 @@ function* expandLet( authority: ExpansionAuthority | undefined, returnBody: ReturnBody | undefined, ): Operation { - const written = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; - if (written.some((name) => !LET_PROPS.has(name))) { - return [yield* raise(letError(' only accepts "as", "value" and "select" props.'))]; - } - - if ("as" in segment.expressions) { - return [yield* raise(letError(' is invalid: "as" must be a string literal.'))]; - } - - if (segment.props.as === undefined) { - return [yield* raise(letError(' requires an "as" prop (non-empty string).'))]; - } - - const asBinding = validateBindingName(segment.props.as); - if (!asBinding.ok) { - return [yield* raise(letError(asBinding.error.message))]; - } - const bindingName = asBinding.value; - if (bindingName === undefined) { - return [yield* raise(letError(' requires an "as" prop (non-empty string).'))]; - } - - // Presence, not the resolved value: `value={undefined}` names the direct - // source exactly as `value={42}` does, and a whitespace child is a body. + // Every one of these is decided from what the author wrote, so the whole + // catalog lives in `structural-rules.ts` where validation reads it too. The + // first is reported and the rest of the construct does not run, which is what + // a `` whose declaration is wrong has always done. + const violations = letViolations(segment); + const refusal = violations[0]; + if (refusal !== undefined) { + return [yield* raise(letError(refusal.message))]; + } + const bindingName = letBindingName(segment)!; const hasValue = "value" in segment.props || "value" in segment.expressions; - const hasSelect = "select" in segment.props || "select" in segment.expressions; - const hasChildren = segment.children.length > 0; - - if (hasValue && hasChildren) { - return [ - yield* raise( - letError( - ' has one source. Remove the children or the "value" prop: ' + - '... binds what its body renders, and ' + - ' binds the value itself.', - ), - ), - ]; - } - - if (hasValue && hasSelect) { - return [ - yield* raise( - letError( - ' "select" extracts from rendered content, so it cannot be written with "value".', - ), - ), - ]; - } - - if (!hasValue && !hasChildren) { - return [ - yield* raise( - letError( - ' must have content or a "value" prop. Use ... or ' + - '.', - ), - ), - ]; - } if (hasValue) { return yield* letValue(segment, bindingName); @@ -1527,8 +1523,6 @@ function eachError(message: string): ErrorSegment { return { type: "error", message, source: "Each" }; } -const EACH_PROPS = new Set(["in", "let", "as"]); - /** * Expand the body once per item, binding `let` to the item (spec §6.5 ``). * @@ -1554,40 +1548,16 @@ function* expandEach( authority: ExpansionAuthority | undefined, returnBody: ReturnBody | undefined, ): Operation { - const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( - (n) => !EACH_PROPS.has(n), - ); - if (unknownProp !== undefined) { - return [ - yield* raise( - eachError(` only accepts "in", "let", and "as" props. Got: "${unknownProp}".`), - ), - ]; - } - - if ("let" in segment.expressions) { - return [yield* raise(eachError('Prop "let" on must be a string literal.'))]; - } - if (segment.props.let === undefined) { - return [yield* raise(eachError(' requires a "let" prop (the item binding name).'))]; - } - const letBinding = validateBindingName(segment.props.let); - if (!letBinding.ok) { - return [yield* raise(eachError(`Prop "let" on ${letBinding.error.message}`))]; - } - const name = letBinding.value; - if (name === undefined) { - return [yield* raise(eachError(' requires a "let" prop (the item binding name).'))]; - } - - if ("as" in segment.expressions) { - return [yield* raise(eachError('Prop "as" on must be a string literal.'))]; - } - const asResult = validateBindingName(segment.props.as); - if (!asResult.ok) { - return [yield* raise(eachError(`Prop "as" on ${asResult.error.message}`))]; + // Decided from source alone, so the catalog is shared with validation. A + // literal `in` is checked here too; an expression is a value the document + // computes, and its answer is checked below where it arrives. + const violations = eachViolations(segment); + const refusal = violations[0]; + if (refusal !== undefined) { + return [yield* raise(eachError(refusal.message))]; } - const asBinding = asResult.value; + const name = eachItemBinding(segment)!; + const asBinding = eachCaptureBinding(segment); let items: Json | undefined; if ("in" in segment.props) { @@ -1604,11 +1574,9 @@ function* expandEach( } catch (error) { return [yield* raise(eachError(error instanceof Error ? error.message : String(error)))]; } - } else { - return [yield* raise(eachError(' requires an "in" prop (the array to iterate).'))]; } if (!Array.isArray(items)) { - return [yield* raise(eachError('Prop "in" on must resolve to an array.'))]; + return [yield* raise(eachError(eachItemsViolation(items)!.message))]; } // Effective caller env honors projection through , mirroring @@ -1682,196 +1650,28 @@ function positioned(message: string, segment: ComponentElement): string { return `${message} (${file}${position.line}:${position.column})`; } -function ifError(segment: ComponentElement, message: string): ErrorSegment { - return { type: "error", message: positioned(message, segment), source: "If" }; -} - -function elseError(segment: ComponentElement, message: string): ErrorSegment { - return { type: "error", message: positioned(message, segment), source: "Else" }; -} - /** - * A structural name written where its construct gives it no meaning. + * One shared structural violation as the printed error expansion produces. * - * `` is the one that reaches here in practice: outside an invocation - * there is nothing to project. Naming it reserved is the point — a repository - * file called `Content.md` does not stand in for the syntax. + * The violation says which construct it belongs to and, when the check walked + * past the element it was given, which element it is about — so an `` + * mistake inside an `` is still positioned where it was written. */ -function strayStructuralError(segment: ComponentElement): ErrorSegment { - const name = segment.name; - const detail = - name === "Content" - ? `<${name} /> renders the content its invocation was given, so it means something ` + - "only inside a component's body." - : `<${name} /> is part of a construct that is not open here.`; +function structuralErrorSegment( + violation: StructuralViolation, + fallback: ComponentElement, +): ErrorSegment { return { type: "error", - message: positioned( - `${detail} <${name}> is reserved: it never resolves a component, so a repository ` + - `file named ${name} cannot supply it.`, - segment, - ), - source: name, - }; -} - -function strayElseError(segment: ComponentElement): ErrorSegment { - return elseError( - segment, - " must be a direct child of . is reserved: it never resolves a " + - "component, and only the it belongs to can select it.", - ); -} - -function isElse(segment: Segment): segment is ComponentElement { - return segment.type === "component" && segment.name === "Else"; -} - -/** Markdown puts newlines between block elements; they are not a third branch. */ -function isBlankText(segment: Segment): boolean { - return segment.type === "text" && segment.content.trim() === ""; -} - -function describeSegment(segment: Segment): string { - if (segment.type === "component") { - return `<${segment.name}>`; - } - if (segment.type === "codeBlock") { - return `a \`${segment.language}\` code block`; - } - if (segment.type === "execOutput") { - return "command output"; - } - if (segment.type === "error") { - return "an error"; - } - const text = segment.content.trim().replace(/\s+/g, " "); - return `text "${text.length > 30 ? `${text.slice(0, 30)}…` : text}"`; -} - -function trailingContentError(segment: Segment, elseElement: ComponentElement): ErrorSegment { - // A component carries its own position; anything else is anchored to the - // `` it follows, which is the boundary the author crossed. - const anchor = segment.type === "component" ? segment : elseElement; - return elseError( - anchor, - ` must be the final substantive child of . Found ${describeSegment(segment)} ` + - "after .", - ); -} - -function jsonKind(value: Json): string { - if (value === null) { - return "null"; - } - if (Array.isArray(value)) { - return "an array"; - } - if (typeof value === "object") { - return "an object"; - } - return `a ${typeof value}`; -} - -function elseElementViolations(segment: ComponentElement): ErrorSegment[] { - const violations: ErrorSegment[] = []; - const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; - if (names.length > 0) { - violations.push(elseError(segment, ` accepts no props. Got: "${names[0]}".`)); - } - if (segment.selfClosing || segment.children.length === 0) { - violations.push(elseError(segment, " must have content. Use ....")); - } - return violations; -} - -/** - * Every `` below an `` that is not one of its direct children. The - * walk stops at a nested ``, which owns the `` elements beneath it. - */ -function misplacedElseViolations(children: Segment[]): ErrorSegment[] { - const violations: ErrorSegment[] = []; - - const walk = (segments: Segment[], depth: number): void => { - for (const segment of segments) { - if (segment.type !== "component" || segment.name === "If") { - continue; - } - if (segment.name === "Else" && depth > 0) { - violations.push(strayElseError(segment)); - } - walk(segment.children, depth + 1); - } + message: positioned(violation.message, violation.element ?? fallback), + source: violation.source, }; - - walk(children, 0); - return violations; -} - -interface IfStructure { - violations: ErrorSegment[]; - whenTrue: Segment[]; - whenFalse: Segment[]; - /** - * The `` element, and where it sat among the ``'s children. - * - * `` is consumed here and never reaches expansion's dispatch, so it - * would contribute no frame of its own — and the two arms of one `` would - * expand under the same path (§5.6). - */ - elseElement?: ComponentElement; - elseIndex?: number; } -/** - * Split an `` body at its `` and validate the split. Structure is - * read from source, before either branch expands, so a malformed `` is - * diagnosed even when it sits in the branch the condition does not select. - * - * `` has exactly two branches, so `` is the final substantive child: - * content after `` belongs to neither branch and is rejected rather than - * silently folded into the true one. - */ -function ifStructure(segment: ComponentElement): IfStructure { - const violations: ErrorSegment[] = []; - const whenTrue: Segment[] = []; - let whenFalse: Segment[] | undefined; - let elseElement: ComponentElement | undefined; - - let elseIndex: number | undefined; - - for (const [index, child] of segment.children.entries()) { - if (isElse(child)) { - if (elseElement) { - violations.push(elseError(child, " accepts at most one branch.")); - continue; - } - violations.push(...elseElementViolations(child)); - elseElement = child; - elseIndex = index; - whenFalse = child.children; - continue; - } - if (!elseElement) { - whenTrue.push(child); - continue; - } - if (!isBlankText(child)) { - violations.push(trailingContentError(child, elseElement)); - } - } - - violations.push(...misplacedElseViolations(segment.children)); - return { - violations, - whenTrue, - whenFalse: whenFalse ?? [], - ...(elseElement === undefined ? {} : { elseElement, elseIndex }), - }; +function ifError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "If" }; } -const IF_PROPS = new Set(["condition"]); - /** * Expand the one branch the condition selects (spec §6.5 ``). The other * branch is never expanded, so nothing in it imports a component, runs a code @@ -1902,22 +1702,18 @@ function* expandIf( authority: ExpansionAuthority | undefined, returnBody: ReturnBody | undefined, ): Operation { - const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( - (name) => !IF_PROPS.has(name), - ); + // Decided from source alone and shared with validation: which props were + // written, and how the body splits at its ``. + const unknownProp = ifPropsViolation(segment); if (unknownProp !== undefined) { - owner.push( - yield* raise( - ifError(segment, ` only accepts a "condition" prop. Got: "${unknownProp}".`), - ), - ); + owner.push(yield* raise(ifError(segment, unknownProp.message))); return; } const structure = ifStructure(segment); if (structure.violations.length > 0) { for (const violation of structure.violations) { - owner.push(yield* raise(violation)); + owner.push(yield* raise(structuralErrorSegment(violation, segment))); } return; } @@ -1945,7 +1741,7 @@ function* expandIf( return; } } else { - owner.push(yield* raise(ifError(segment, ' requires a "condition" prop.'))); + owner.push(yield* raise(ifError(segment, ifConditionViolation(segment)!.message))); return; } @@ -1979,12 +1775,6 @@ function* expandIf( ); } -/** How a `` names itself in its own printed errors. */ -function loopTag(segment: ComponentElement): string { - const name = segment.props.name; - return typeof name === "string" && name.length > 0 ? `` : ""; -} - function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } @@ -1993,13 +1783,11 @@ function breakError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Break" }; } -const LOOP_PROPS = new Set(["max", "name"]); - /** * The bound a `` runs to, or why the prop rejects it. The caller turns * the failure into a positioned printed error, because it is the one that raises. */ -function* loopBound(segment: ComponentElement): Operation> { +function* resolveLoopBound(segment: ComponentElement): Operation> { let max: Json; if ("max" in segment.props) { max = segment.props.max; @@ -2016,29 +1804,10 @@ function* loopBound(segment: ComponentElement): Operation> { return Err(error); } } else { - return Err( - new Error( - `${loopTag(segment)} requires a "max" prop (a positive integer). Repetition is ` + - "always bounded — there is no unbounded loop.", - ), - ); + return Err(new Error(loopMissingBoundMessage(segment))); } - if (typeof max !== "number") { - return Err( - new Error( - `Prop "max" on ${loopTag(segment)} must be a positive integer, not ${jsonKind(max)}.`, - ), - ); - } - if (!Number.isInteger(max) || max < 1) { - return Err( - new Error( - `Prop "max" on ${loopTag(segment)} must be a positive integer. Got: ${JSON.stringify(max)}.`, - ), - ); - } - return Ok(max); + return loopBound(segment, max); } /** @@ -2079,15 +1848,9 @@ function* expandLoop( authority: ExpansionAuthority | undefined, returnBody: ReturnBody | undefined, ): Operation { - const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( - (name) => !LOOP_PROPS.has(name), - ); + const unknownProp = loopPropsViolation(segment); if (unknownProp !== undefined) { - owner.push( - yield* raise( - loopError(segment, ` only accepts "max" and "name" props. Got: "${unknownProp}".`), - ), - ); + owner.push(yield* raise(loopError(segment, unknownProp.message))); return; } @@ -2105,7 +1868,7 @@ function* expandLoop( return; } - const bound = yield* loopBound(segment); + const bound = yield* resolveLoopBound(segment); if (!bound.ok) { owner.push(yield* raise(loopError(segment, bound.error.message))); return; @@ -2183,18 +1946,6 @@ function* expandLoop( yield* recordOutcome(identity, { iterations: started, outcome }); } -function breakElementViolations(segment: ComponentElement): string[] { - const violations: string[] = []; - const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; - if (names.length > 0) { - violations.push(` accepts no props. Got: "${names[0]}".`); - } - if (!segment.selfClosing || segment.children.length > 0) { - violations.push(" takes no content. Write it self-closing: ."); - } - return violations; -} - /** * Exit the nearest enclosing `` (spec §6.5 ``). * @@ -2221,11 +1972,7 @@ function* expandBreak( } if (!loop) { - violations.unshift( - " must be written inside a . is reserved: it never resolves a " + - "component, and a a component writes in its own body cannot break the loop " + - "that invoked it.", - ); + violations.unshift(strayBreakMessage()); } const reported: Segment[] = []; @@ -2267,13 +2014,9 @@ function* expandPrintErrors( authority: ExpansionAuthority | undefined, returnBody: ReturnBody | undefined, ): Operation { - const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; - if (names.length > 0) { - owner.push( - yield* raise( - printErrorsPropError(segment, ` accepts no props. Got: "${names[0]}".`), - ), - ); + const refusal = printErrorsViolations(segment)[0]; + if (refusal !== undefined) { + owner.push(yield* raise(printErrorsPropError(segment, refusal.message))); return; } @@ -3768,234 +3511,14 @@ interface BodyChunk { declaration?: boolean; } -function isTopLevelOutput(segment: Segment): boolean { - return segment.type === "component" && segment.name === "Output"; -} - -export function bodyHasOutput(bodySegments: Segment[]): boolean { - return bodySegments.some(isTopLevelOutput); -} - -function misplacedOutputError(): ErrorSegment { - return { - type: "error", - message: - " must be a direct top-level child of the component or document " + - "that declares it. For conditional rendering, use inside .", - source: "Output", - }; -} - -function misplacedReturnError(segment: ComponentElement): ErrorSegment { - return { - type: "error", - message: - `${previewReturn(segment)} is not written in the flow of a body that declares ` + - "`returns`, so there is no declaration for it to satisfy. is reserved: " + - "it never resolves a component, and neither markdown produced at runtime nor " + - "another component's body can declare one.", - source: "Return", - }; -} - -function previewOutput(segment: ComponentElement): string { - const text = segment.children - .filter((child): child is TextSegment => child.type === "text") - .map((child) => child.content) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - if (text.length === 0) { - return " (empty)"; - } - const clipped = text.slice(0, 40); - return ` containing "${clipped}${text.length > 40 ? "…" : ""}"`; -} - /** - * Structural preflight (spec §6.9). Validates `` placement against the - * body's own source AST. Only a direct top-level `` is a valid - * declaration; any `` at depth > 0 — including inside unreachable or - * discarded children — is a placement violation. All violations are combined - * into a single aggregate ErrorSegment. Returns undefined when placement is - * valid. - */ -export function validateOutputPlacement(bodySegments: Segment[]): ErrorSegment | undefined { - const violations: string[] = []; - - const walk = (segments: Segment[], depth: number): void => { - for (const segment of segments) { - if (segment.type !== "component") { - continue; - } - if (segment.name === "Output" && depth > 0) { - violations.push(previewOutput(segment)); - } - walk(segment.children, depth + 1); - } - }; - - walk(bodySegments, 0); - - if (violations.length === 0) { - return undefined; - } - - const list = violations.map((entry) => ` - ${entry}`).join("\n"); - return { - type: "error", - message: - " must be a direct top-level child of the component or document " + - "that declares it. For conditional rendering, use inside " + - `. Misplaced found:\n${list}`, - source: "Output", - }; -} - -function previewReturn(segment: ComponentElement): string { - if ("value" in segment.expressions) { - return ``; - } - if ("value" in segment.props) { - return ``; - } - return ""; -} - -function structureError(source: string, headline: string, violations: string[]): ErrorSegment { - const list = violations.map((entry) => ` - ${entry}`).join("\n"); - return { type: "error", message: `${headline}\n${list}`, source }; -} - -/** - * Every `` the body itself declares, at any depth. + * The body output/return contract, read once in `body-structure.ts`. * - * Depth is not a violation: a return under `` or inside a `` is - * written in the body's own flow and is reached by ordinary expansion. What - * this walk does not see is the only thing that still cannot declare one — - * another component's definition, and markdown produced at runtime — because - * it reads this body's source AST and nothing else. - */ -function collectReturns(bodySegments: Segment[]): ComponentElement[] { - const declared: ComponentElement[] = []; - - const walk = (segments: Segment[]): void => { - for (const segment of segments) { - if (segment.type !== "component") { - continue; - } - if (segment.name === "Return") { - declared.push(segment); - } - walk(segment.children); - } - }; - - walk(bodySegments); - return declared; -} - -function returnElementViolations(segment: ComponentElement): string[] { - const violations: string[] = []; - const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; - const extra = names.filter((name) => name !== "value"); - if (extra.length > 0) { - violations.push(`${previewReturn(segment)} accepts only a "value" prop, got "${extra[0]}"`); - } - if (!names.includes("value")) { - violations.push(`${previewReturn(segment)} requires a "value" prop`); - } - if (segment.children.length > 0) { - violations.push(`${previewReturn(segment)} takes no children`); - } - return violations; -} - -function textModeReturnError(bodySegments: Segment[]): ErrorSegment | undefined { - const declared = collectReturns(bodySegments); - if (declared.length === 0) { - return undefined; - } - const found = declared.map(previewReturn); - return structureError( - "Return", - " requires a document or component that declares `returns`. Declare a " + - "return schema, or remove . Found:", - found, - ); -} - -function valueModeStructureError(bodySegments: Segment[]): ErrorSegment | undefined { - const violations: string[] = []; - - const walkOutput = (segments: Segment[]): void => { - for (const segment of segments) { - if (segment.type !== "component") { - continue; - } - if (segment.name === "Output") { - violations.push(`${previewOutput(segment)} — and \`returns\` are exclusive`); - } - walkOutput(segment.children); - } - }; - walkOutput(bodySegments); - - const declared = collectReturns(bodySegments); - - if (declared.length === 0) { - violations.push("no "); - } - for (const declaration of declared) { - violations.push(...returnElementViolations(declaration)); - } - - if (violations.length === 0) { - return undefined; - } - return structureError( - "Return", - "A component that declares `returns` renders nothing and produces exactly one " + - "value through a its own body executes. Problems found:", - violations, - ); -} - -/** - * Structural preflight for a body's output and return contract (spec §6.9, - * §6.10). Runs against the body's own source AST, before `` - * substitution, so projected content can neither introduce nor satisfy a - * declaration. Every violation is combined into a single ErrorSegment, and a - * body whose structure is invalid runs no eval, exec, ``, or nested - * component. + * Re-exported here because expansion is where these have always been reached + * from; the walk and the catalog now live beside the facts validation reads, so + * the two callers cannot drift. */ -export function validateBodyStructure( - bodySegments: Segment[], - returns: ReturnsSchema | undefined, -): ErrorSegment | undefined { - if (returns !== undefined) { - return valueModeStructureError(bodySegments); - } - const outputError = validateOutputPlacement(bodySegments); - const returnError = textModeReturnError(bodySegments); - if (outputError && returnError) { - return { - type: "error", - message: `${outputError.message}\n\n${returnError.message}`, - source: "Return", - }; - } - return outputError ?? returnError; -} - -function validateOutputProps(segment: ComponentElement): ErrorSegment | undefined { - const hasProps = Object.keys(segment.props).length > 0; - const hasExpressions = Object.keys(segment.expressions).length > 0; - if (hasProps || hasExpressions) { - return { type: "error", message: " accepts no props.", source: "Output" }; - } - return undefined; -} +export { bodyHasOutput, validateBodyStructure, validateOutputPlacement }; /** * Partition a definition body into ordered chunks (spec §6.9). Output error mode @@ -4019,9 +3542,13 @@ function buildBody( for (const [index, segment] of bodySegments.entries()) { if (segment.type === "component" && segment.name === "Output") { - const propsError = validateOutputProps(segment); - if (propsError) { - chunks.push({ output: true, segments: [propsError], declaration: true }); + const propsViolation = outputPropsViolation(segment); + if (propsViolation !== undefined) { + chunks.push({ + output: true, + segments: [{ type: "error", message: propsViolation, source: "Output" }], + declaration: true, + }); continue; } const outputSegments = substituteSegmentList(segment.children, slots, project, state, claim); diff --git a/packages/core/src/frontmatter.ts b/packages/core/src/frontmatter.ts index 386a01916..3a79245b4 100644 --- a/packages/core/src/frontmatter.ts +++ b/packages/core/src/frontmatter.ts @@ -1,3 +1,6 @@ +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + import { parseJsonObject } from "./json.ts"; import type { Json, JsonObject, PropsSchema, ReturnsSchema } from "./types.ts"; @@ -11,14 +14,103 @@ const DRAFT_07 = "http://json-schema.org/draft-07/schema#"; const RESERVED_KEYS = ["props", "required", "returns"]; -export function parseFrontmatter(raw: unknown): ParsedFrontmatter { - const root: JsonObject = raw === null || raw === undefined ? {} : parseJsonObject(raw); +/** + * Which declaration a frontmatter failure came from. + * + * Reading frontmatter is three decisions in a fixed order — the envelope, the + * props declaration, then the returns declaration — and each has its own + * remedy. A caller that reports the failure rather than throwing it needs to + * know which one it was, and the only honest place to answer that is where the + * decision is made. Nothing here classifies a failure by reading its message. + */ +export type FrontmatterPhase = "frontmatter" | "props-declaration" | "returns-declaration"; + +/** + * One frontmatter failure, carrying the decision that produced it. + * + * The original failure travels under `original` so that a caller which raises + * rather than reports raises exactly what it always raised: this wrapper is how + * the phase reaches a reporting caller, not a new error for anyone to see. + */ +export class FrontmatterPhaseError extends Error { + readonly phase: FrontmatterPhase; + readonly original: unknown; + + constructor(phase: FrontmatterPhase, original: unknown) { + super(original instanceof Error ? original.message : String(original), { cause: original }); + this.name = "FrontmatterPhaseError"; + this.phase = phase; + this.original = original; + } +} + +function fail(phase: FrontmatterPhase, original: unknown): Result { + return Err(new FrontmatterPhaseError(phase, original)); +} + +/** + * Read frontmatter, reporting the phase a failure belongs to rather than + * throwing it. + * + * The order is the same one `parseFrontmatter()` has always had, because + * `parseFrontmatter()` is this: an author who wrote both a broken props + * declaration and a broken returns declaration hears about the props one + * whether their document runs or is only validated. + */ +export function parseFrontmatterPhased(raw: unknown): Result { + let root: JsonObject; + try { + root = raw === null || raw === undefined ? {} : parseJsonObject(raw); + } catch (error) { + return fail("frontmatter", error); + } const declaredReturns = root["returns"]; - const parsed: ParsedFrontmatter = { meta: parseMeta(root), props: parsePropsSchema(root) }; + + let meta: Record; + try { + meta = parseMeta(root); + } catch (error) { + return fail("frontmatter", error); + } + + let props: PropsSchema; + try { + props = parsePropsSchema(root); + } catch (error) { + return fail("props-declaration", error); + } + + const parsed: ParsedFrontmatter = { meta, props }; if (declaredReturns !== undefined) { - parsed.returns = parseReturnsDeclaration(declaredReturns); + try { + parsed.returns = parseReturnsDeclaration(declaredReturns); + } catch (error) { + return fail("returns-declaration", error); + } + } + return Ok(parsed); +} + +export function parseFrontmatter(raw: unknown): ParsedFrontmatter { + const outcome = parseFrontmatterPhased(raw); + if (!outcome.ok) { + throw frontmatterFailure(outcome.error).original; + } + return outcome.value; +} + +/** + * The phase failure this error is, or the error itself if it is not one. + * + * Nothing but `parseFrontmatterPhased()` produces the `Err` side above, so + * anything else reaching here is a failure this module did not classify and is + * raised rather than described. + */ +export function frontmatterFailure(error: Error): FrontmatterPhaseError { + if (error instanceof FrontmatterPhaseError) { + return error; } - return parsed; + throw error; } /** diff --git a/packages/core/src/inspect.ts b/packages/core/src/inspect.ts index 285e74f19..7d4ba4fbb 100644 --- a/packages/core/src/inspect.ts +++ b/packages/core/src/inspect.ts @@ -3,12 +3,9 @@ import { readTextFile } from "@executablemd/runtime"; import type { ComponentOrigin, - ComponentRegistry, ComponentSelection, - FunctionComponentDefinition, InvocationForm, PropsSchema, - RegistryEntry, ReturnsSchema, } from "./types.ts"; import { @@ -20,6 +17,7 @@ import type { DocumentTargetInfo } from "./document-targets.ts"; import { Component } from "./component-api.ts"; import { DEFAULT_INCLUDES, effectiveRegistry, selectComponent } from "./components/select.ts"; import { admitDeclaration, mergeRegistry } from "./components/registration.ts"; +import { declaredRegistry } from "./components/declared-registry.ts"; import { repositoryCandidateNames } from "./components/candidates.ts"; import { documentationOf } from "./components/documentation.ts"; import type { ComponentDocumentation } from "./components/documentation.ts"; @@ -443,42 +441,6 @@ function byCodePoint(left: string, right: string): number { return a.length - b.length; } -/** - * The identity declarations a host would make, as registry entries selection - * can decide against. - * - * The implementation slot holds a refusal rather than what the factory would - * build, because building it is the authority this has none of: the factory - * takes an execution's claimant, and there is no execution here. Nothing in - * inspection reaches an implementation, so the refusal is unreachable — it is - * there so that anything which ever did would fail loudly rather than run a - * component with no execution behind it. - */ -function declaredRegistry(components: readonly IdentityComponent[]): ComponentRegistry { - const entries = new Map(); - for (const component of components) { - const definition: FunctionComponentDefinition = { - kind: "function", - name: component.name, - props: component.props, - ...(component.returns === undefined ? {} : { returns: component.returns }), - ...(component.captures === undefined ? {} : { captures: component.captures }), - ...(component.forms === undefined ? {} : { forms: component.forms }), - ...documentationOf(component), - fn: uninvocable, - }; - entries.set(component.name, { default: { definition, origin: component.origin } }); - } - return entries; -} - -// deno-lint-ignore require-yield -function* uninvocable(): Operation { - throw new Error( - "this component was described rather than executed, so it has no implementation to run", - ); -} - function structuralEntry(construct: string): StructuralSyntaxEntry { const declaration = STRUCTURAL_DECLARATIONS.find((candidate) => candidate.name === construct); if (declaration === undefined) { diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts new file mode 100644 index 000000000..bbb637fe1 --- /dev/null +++ b/packages/core/src/structural-rules.ts @@ -0,0 +1,734 @@ +/** + * What a structural construct's own source says, before anything runs. + * + * Every construct the engine owns states part of its contract in syntax alone: + * which props it accepts, which of them must be a literal, which children it + * may hold, and where it may be written at all. None of those answers depends + * on a value the document computes, so all of them are decided here — once, as + * data — and both expansion and validation read the result. + * + * The checks are pure and yield nothing. Expansion turns a violation into the + * positioned printed error it has always produced; validation reports the same + * violation under its own code at the position it was authored. Neither owns + * the rule, so a construct cannot be refused by one and accepted by the other. + * + * What is *not* here is anything needing a runtime value: whether a condition + * holds, what an `in` expression evaluates to, whether a loop's `max` came back + * a number. Those stay in expansion, which is the only place that may evaluate + * them. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import { validateBindingName } from "./live-env.ts"; +import type { ComponentElement, Json, Segment } from "./types.ts"; + +/** + * Which part of the contract a structural violation broke. + * + * The names are the validation codes rather than construct names, because a + * construct breaks more than one kind of rule: a `` may name its binding + * badly or hold the wrong number of sources, and a consumer that acts on the + * difference should not have to tell them apart by reading the sentence. + */ +export type StructuralViolationCode = + | "structural-usage-invalid" + | "binding-invalid" + | "capture-invalid" + | "return-usage-invalid"; + +/** One thing a construct's source got wrong. */ +export interface StructuralViolation { + readonly code: StructuralViolationCode; + /** The sentence, unpositioned. Expansion positions the ones it always did. */ + readonly message: string; + /** The construct a printed error names as its source. */ + readonly source: string; + /** + * The element the violation is about, when the check walked past the element + * it was given. An `` reports its `` children's mistakes, and each + * one is anchored where it was written rather than at the ``. + */ + readonly element?: ComponentElement; +} + +function violation( + code: StructuralViolationCode, + source: string, + message: string, + element?: ComponentElement, +): StructuralViolation { + return element === undefined ? { code, source, message } : { code, source, message, element }; +} + +/** Every prop name written on an element, literal and expression alike. */ +export function authoredPropNames(segment: ComponentElement): string[] { + return [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; +} + +/** Markdown puts newlines between block elements; they are not content. */ +export function isBlankText(segment: Segment): boolean { + return segment.type === "text" && segment.content.trim() === ""; +} + +const LET_PROPS = new Set(["as", "value", "select"]); + +/** + * Everything `` decides from what the author wrote (spec §6.5). + * + * Which source a `` has — rendered content or a named value — is read from + * presence rather than from a resolved value, so a construct naming both + * sources evaluates neither. Expansion reports the first of these and stops, + * which is what it has always done; the whole list exists so validation can + * report each one where it sits. + */ +export function letViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const written = authoredPropNames(segment); + if (written.some((name) => !LET_PROPS.has(name))) { + found.push( + violation( + "structural-usage-invalid", + "Let", + ' only accepts "as", "value" and "select" props.', + ), + ); + } + + if ("as" in segment.expressions) { + found.push( + violation( + "binding-invalid", + "Let", + ' is invalid: "as" must be a string literal.', + ), + ); + } else if (segment.props.as === undefined) { + found.push( + violation("binding-invalid", "Let", ' requires an "as" prop (non-empty string).'), + ); + } else { + const asBinding = validateBindingName(segment.props.as); + if (!asBinding.ok) { + found.push(violation("binding-invalid", "Let", asBinding.error.message)); + } else if (asBinding.value === undefined) { + found.push( + violation("binding-invalid", "Let", ' requires an "as" prop (non-empty string).'), + ); + } + } + + // Presence, not the resolved value: `value={undefined}` names the direct + // source exactly as `value={42}` does, and a whitespace child is a body. + const hasValue = "value" in segment.props || "value" in segment.expressions; + const hasSelect = "select" in segment.props || "select" in segment.expressions; + const hasChildren = segment.children.length > 0; + + if (hasValue && hasChildren) { + found.push( + violation( + "structural-usage-invalid", + "Let", + ' has one source. Remove the children or the "value" prop: ' + + '... binds what its body renders, and ' + + ' binds the value itself.', + ), + ); + } + if (hasValue && hasSelect) { + found.push( + violation( + "structural-usage-invalid", + "Let", + ' "select" extracts from rendered content, so it cannot be written with "value".', + ), + ); + } + if (!hasValue && !hasChildren) { + found.push( + violation( + "structural-usage-invalid", + "Let", + ' must have content or a "value" prop. Use ... or ' + + '.', + ), + ); + } + return found; +} + +/** The binding name a well-formed `` writes into. */ +export function letBindingName(segment: ComponentElement): string | undefined { + const binding = validateBindingName(segment.props.as); + return binding.ok ? binding.value : undefined; +} + +const EACH_PROPS = new Set(["in", "let", "as"]); + +/** + * Everything `` decides from what the author wrote (spec §6.5). + * + * The `in` prop is here only when it is a literal: an expression is a value the + * document computes, and whether it produced an array is expansion's to find + * out. `eachItemsViolation()` below is the same rule applied to that answer. + */ +export function eachViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknownProp = authoredPropNames(segment).find((name) => !EACH_PROPS.has(name)); + if (unknownProp !== undefined) { + found.push( + violation( + "structural-usage-invalid", + "Each", + ` only accepts "in", "let", and "as" props. Got: "${unknownProp}".`, + ), + ); + } + + if ("let" in segment.expressions) { + found.push( + violation("binding-invalid", "Each", 'Prop "let" on must be a string literal.'), + ); + } else if (segment.props.let === undefined) { + found.push( + violation("binding-invalid", "Each", ' requires a "let" prop (the item binding name).'), + ); + } else { + const letBinding = validateBindingName(segment.props.let); + if (!letBinding.ok) { + found.push( + violation("binding-invalid", "Each", `Prop "let" on ${letBinding.error.message}`), + ); + } else if (letBinding.value === undefined) { + found.push( + violation( + "binding-invalid", + "Each", + ' requires a "let" prop (the item binding name).', + ), + ); + } + } + + if ("as" in segment.expressions) { + found.push( + violation("binding-invalid", "Each", 'Prop "as" on must be a string literal.'), + ); + } else { + const asResult = validateBindingName(segment.props.as); + if (!asResult.ok) { + found.push( + violation("binding-invalid", "Each", `Prop "as" on ${asResult.error.message}`), + ); + } + } + + if ("in" in segment.props) { + const items = eachItemsViolation(segment.props.in); + if (items !== undefined) { + found.push(items); + } + } else if (!("in" in segment.expressions)) { + found.push( + violation( + "structural-usage-invalid", + "Each", + ' requires an "in" prop (the array to iterate).', + ), + ); + } + return found; +} + +/** The item binding a well-formed `` writes each element into. */ +export function eachItemBinding(segment: ComponentElement): string | undefined { + const binding = validateBindingName(segment.props.let); + return binding.ok ? binding.value : undefined; +} + +/** The capture a well-formed `` writes its whole rendering into. */ +export function eachCaptureBinding(segment: ComponentElement): string | undefined { + const binding = validateBindingName(segment.props.as); + return binding.ok ? binding.value : undefined; +} + +/** What `` says about a value that is not an array. */ +export function eachItemsViolation(items: Json | undefined): StructuralViolation | undefined { + return Array.isArray(items) + ? undefined + : violation( + "structural-usage-invalid", + "Each", + 'Prop "in" on must resolve to an array.', + ); +} + +const IF_PROPS = new Set(["condition"]); + +/** The one prop `` accepts, decided from what was written. */ +export function ifPropsViolation(segment: ComponentElement): StructuralViolation | undefined { + const unknownProp = authoredPropNames(segment).find((name) => !IF_PROPS.has(name)); + return unknownProp === undefined + ? undefined + : violation( + "structural-usage-invalid", + "If", + ` only accepts a "condition" prop. Got: "${unknownProp}".`, + ); +} + +/** An `` that names no condition at all names nothing to decide. */ +export function ifConditionViolation(segment: ComponentElement): StructuralViolation | undefined { + return "condition" in segment.props || "condition" in segment.expressions + ? undefined + : violation("structural-usage-invalid", "If", ' requires a "condition" prop.'); +} + +function isElse(segment: Segment): segment is ComponentElement { + return segment.type === "component" && segment.name === "Else"; +} + +function describeSegment(segment: Segment): string { + if (segment.type === "component") { + return `<${segment.name}>`; + } + if (segment.type === "codeBlock") { + return `a \`${segment.language}\` code block`; + } + if (segment.type === "execOutput") { + return "command output"; + } + if (segment.type === "error") { + return "an error"; + } + const text = segment.content.trim().replace(/\s+/g, " "); + return `text "${text.length > 30 ? `${text.slice(0, 30)}…` : text}"`; +} + +/** What an `` written outside the `` that selects it says. */ +export function strayElseMessage(): string { + return ( + " must be a direct child of . is reserved: it never resolves a " + + "component, and only the it belongs to can select it." + ); +} + +/** + * A structural name written where its construct gives it no meaning. + * + * `` is the one that reaches here in practice: outside an invocation + * there is nothing to project. Naming it reserved is the point — a repository + * file called `Content.md` does not stand in for the syntax. + */ +export function strayStructuralMessage(name: string): string { + const detail = + name === "Content" + ? `<${name} /> renders the content its invocation was given, so it means something ` + + "only inside a component's body." + : `<${name} /> is part of a construct that is not open here.`; + return ( + `${detail} <${name}> is reserved: it never resolves a component, so a repository ` + + `file named ${name} cannot supply it.` + ); +} + +function elseElementViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const names = authoredPropNames(segment); + if (names.length > 0) { + found.push( + violation( + "structural-usage-invalid", + "Else", + ` accepts no props. Got: "${names[0]}".`, + segment, + ), + ); + } + if (segment.selfClosing || segment.children.length === 0) { + found.push( + violation( + "structural-usage-invalid", + "Else", + " must have content. Use ....", + segment, + ), + ); + } + return found; +} + +/** + * Every `` below an `` that is not one of its direct children. The + * walk stops at a nested ``, which owns the `` elements beneath it. + */ +function misplacedElseViolations(children: Segment[]): StructuralViolation[] { + const found: StructuralViolation[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component" || segment.name === "If") { + continue; + } + if (segment.name === "Else" && depth > 0) { + found.push(violation("structural-usage-invalid", "Else", strayElseMessage(), segment)); + } + walk(segment.children, depth + 1); + } + }; + + walk(children, 0); + return found; +} + +/** + * Content written after `` belongs to neither branch. + * + * A component carries its own position; anything else is anchored to the + * `` it follows, which is the boundary the author crossed. + */ +function trailingContentViolation( + segment: Segment, + elseElement: ComponentElement, +): StructuralViolation { + const anchor = segment.type === "component" ? segment : elseElement; + return violation( + "structural-usage-invalid", + "Else", + ` must be the final substantive child of . Found ${describeSegment(segment)} ` + + "after .", + anchor, + ); +} + +/** How an `` body splits at its ``, and what the split got wrong. */ +export interface IfStructure { + readonly violations: StructuralViolation[]; + readonly whenTrue: Segment[]; + readonly whenFalse: Segment[]; + /** + * The `` element, and where it sat among the ``'s children. + * + * `` is consumed by its `` and never reaches expansion's dispatch, + * so it would contribute no frame of its own — and the two arms of one `` + * would expand under the same path (§5.6). + */ + readonly elseElement?: ComponentElement; + readonly elseIndex?: number; +} + +/** + * Split an `` body at its `` and validate the split. Structure is + * read from source, before either branch expands, so a malformed `` is + * diagnosed even when it sits in the branch the condition does not select. + * + * `` has exactly two branches, so `` is the final substantive child: + * content after `` belongs to neither branch and is rejected rather than + * silently folded into the true one. + */ +export function ifStructure(segment: ComponentElement): IfStructure { + const violations: StructuralViolation[] = []; + const whenTrue: Segment[] = []; + let whenFalse: Segment[] | undefined; + let elseElement: ComponentElement | undefined; + let elseIndex: number | undefined; + + for (const [index, child] of segment.children.entries()) { + if (isElse(child)) { + if (elseElement) { + violations.push( + violation( + "structural-usage-invalid", + "Else", + " accepts at most one branch.", + child, + ), + ); + continue; + } + violations.push(...elseElementViolations(child)); + elseElement = child; + elseIndex = index; + whenFalse = child.children; + continue; + } + if (!elseElement) { + whenTrue.push(child); + continue; + } + if (!isBlankText(child)) { + violations.push(trailingContentViolation(child, elseElement)); + } + } + + violations.push(...misplacedElseViolations(segment.children)); + return { + violations, + whenTrue, + whenFalse: whenFalse ?? [], + ...(elseElement === undefined ? {} : { elseElement, elseIndex }), + }; +} + +const LOOP_PROPS = new Set(["max", "name"]); + +/** How a `` names itself in its own printed errors. */ +export function loopTag(segment: ComponentElement): string { + const name = segment.props.name; + return typeof name === "string" && name.length > 0 ? `` : ""; +} + +function jsonKind(value: Json): string { + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return "an array"; + } + if (typeof value === "object") { + return "an object"; + } + return `a ${typeof value}`; +} + +/** The one prop set `` accepts, decided from what was written. */ +export function loopPropsViolation(segment: ComponentElement): StructuralViolation | undefined { + const unknownProp = authoredPropNames(segment).find((name) => !LOOP_PROPS.has(name)); + return unknownProp === undefined + ? undefined + : violation( + "structural-usage-invalid", + "Loop", + ` only accepts "max" and "name" props. Got: "${unknownProp}".`, + ); +} + +/** + * The bound a `` runs to, or why `max` rejects it. + * + * The same rule wherever the value came from: a literal `max` is checked here + * while the document is only being read, and an expression's answer is checked + * here too once expansion has evaluated it. + */ +export function loopBound(segment: ComponentElement, max: Json): Result { + if (typeof max !== "number") { + return Err( + new Error( + `Prop "max" on ${loopTag(segment)} must be a positive integer, not ${jsonKind(max)}.`, + ), + ); + } + if (!Number.isInteger(max) || max < 1) { + return Err( + new Error( + `Prop "max" on ${loopTag(segment)} must be a positive integer. Got: ${JSON.stringify(max)}.`, + ), + ); + } + return Ok(max); +} + +/** What a `` naming no bound at all says. */ +export function loopMissingBoundMessage(segment: ComponentElement): string { + return ( + `${loopTag(segment)} requires a "max" prop (a positive integer). Repetition is ` + + "always bounded — there is no unbounded loop." + ); +} + +/** + * Everything `` decides from what the author wrote (spec §6.5). + * + * A `max` written as an expression is a value the document computes, so its + * bound is expansion's to check with `loopBound()` once it has one. + */ +export function loopViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknown = loopPropsViolation(segment); + if (unknown !== undefined) { + found.push(unknown); + } + if ("max" in segment.props) { + const bound = loopBound(segment, segment.props.max); + if (!bound.ok) { + found.push(violation("structural-usage-invalid", "Loop", bound.error.message)); + } + } else if (!("max" in segment.expressions)) { + found.push(violation("structural-usage-invalid", "Loop", loopMissingBoundMessage(segment))); + } + return found; +} + +/** What a `` element itself got wrong, as expansion words it. */ +export function breakElementViolations(segment: ComponentElement): string[] { + const violations: string[] = []; + const names = authoredPropNames(segment); + if (names.length > 0) { + violations.push(` accepts no props. Got: "${names[0]}".`); + } + if (!segment.selfClosing || segment.children.length > 0) { + violations.push(" takes no content. Write it self-closing: ."); + } + return violations; +} + +/** What a `` written outside every `` says. */ +export function strayBreakMessage(): string { + return ( + " must be written inside a . is reserved: it never resolves a " + + "component, and a a component writes in its own body cannot break the loop " + + "that invoked it." + ); +} + +/** + * Everything `` decides from what the author wrote and from where it was + * written (spec §6.5). + * + * Whether a `` encloses it is lexical, so it is decided here too: a + * `` written in a component's own body cannot break the loop that + * invoked that component, however the run reaches it. + */ +export function breakViolations( + segment: ComponentElement, + insideLoop: boolean, +): StructuralViolation[] { + const messages = breakElementViolations(segment); + if (!insideLoop) { + messages.unshift(strayBreakMessage()); + } + return messages.map((message) => violation("structural-usage-invalid", "Break", message)); +} + +/** + * `` names a region and nothing else, so it takes no props at all + * — including `as` and `slot`, which are ordinary prop entries here rather than + * fields of their own. + */ +export function printErrorsViolations(segment: ComponentElement): StructuralViolation[] { + const names = authoredPropNames(segment); + return names.length === 0 + ? [] + : [ + violation( + "structural-usage-invalid", + "PrintErrors", + ` accepts no props. Got: "${names[0]}".`, + ), + ]; +} + +/** What an `` written outside the `` that reads it says. */ +export function strayAnswerMessage(): string { + return ( + " must be a direct child of . It is reserved: it never resolves a " + + "component, and only the it belongs to can read it." + ); +} + +/** What an unexpected prop on `` says. */ +export function answersPropMessage(name: string): string { + return ` does not accept a "${name}" prop (allowed: delegate).`; +} + +/** What a `delegate` that is not a boolean says, wherever the value came from. */ +export function answersDelegateMessage(described: string): string { + return ` delegate must be a boolean — ${described}`; +} + +/** What a literal `delegate` that is not a boolean says. */ +export function answersLiteralDelegateMessage(raw: Json): string { + return answersDelegateMessage(`write delegate={true}, not delegate=${JSON.stringify(raw)}.`); +} + +/** What an `` with nothing to answer for says. */ +export function answersNoBodyMessage(): string { + return ( + " has no body to answer for. It wraps the region whose elicitations it " + + "answers, so an containing only matchers can never do anything." + ); +} + +/** + * Everything `` decides from what the author wrote (spec §6.16.2). + * + * A `delegate` written as an expression is a value the document computes; + * whether it came back a boolean is expansion's to find out. The body check is + * static: matchers are `` children, and what is left over is the region + * the element answers for. + */ +export function answersViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + for (const name of authoredPropNames(segment)) { + if (name !== "delegate") { + found.push(violation("structural-usage-invalid", "Answers", answersPropMessage(name))); + } + } + if (!("delegate" in segment.expressions) && "delegate" in segment.props) { + const raw = segment.props.delegate; + if (typeof raw !== "boolean") { + found.push( + violation("structural-usage-invalid", "Answers", answersLiteralDelegateMessage(raw)), + ); + } + } + const body = segment.children.filter( + (child) => !(child.type === "component" && child.name === "Answer"), + ); + if (segment.selfClosing || body.every(isBlankText)) { + found.push(violation("structural-usage-invalid", "Answers", answersNoBodyMessage())); + } + return found; +} + +/** What an unexpected prop on `` says. */ +export function answerPropMessage(name: string): string { + return ` does not accept a "${name}" prop (allowed: template, value).`; +} + +/** What an `` says. */ +export function answerTemplateExpressionMessage(): string { + return ( + " template must be a literal string prop or template children, not an " + + "expression. Write the bindings a template references as {binding} holes inside it." + ); +} + +/** What an `` writing its template twice says. */ +export function answerTemplateBothMessage(): string { + return " accepts either a template prop or template children, not both."; +} + +/** What an `` supplying nothing says. */ +export function answerMissingValueMessage(): string { + return ' requires a "value" prop.'; +} + +/** + * Everything one `` matcher decides from what the author wrote. + * + * The template itself is not parsed here when it is written as children: those + * children render, and rendering is expansion's. A literal `template` prop is a + * string the author wrote, so the rest of the matcher's shape is decided from + * presence alone. + */ +export function answerViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + for (const name of authoredPropNames(segment)) { + if (name !== "template" && name !== "value") { + found.push(violation("structural-usage-invalid", "Answer", answerPropMessage(name))); + } + } + if ("template" in segment.expressions) { + found.push(violation("structural-usage-invalid", "Answer", answerTemplateExpressionMessage())); + } + const hasChildren = !segment.selfClosing && segment.children.length > 0; + if (typeof segment.props.template === "string" && hasChildren) { + found.push(violation("structural-usage-invalid", "Answer", answerTemplateBothMessage())); + } + if (!("value" in segment.props) && !("value" in segment.expressions)) { + found.push(violation("structural-usage-invalid", "Answer", answerMissingValueMessage())); + } + return found; +} diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts new file mode 100644 index 000000000..af143b5ac --- /dev/null +++ b/packages/core/tests/document-validation.test.ts @@ -0,0 +1,861 @@ +/** + * Tier DV — validating a supplied document without executing it. + * + * The boundary answers one question: is this authored program structure, as far + * as anything decidable without running it can say? These rows drive + * `validateDocument()` against a stubbed contextual filesystem, so what a row + * asserts is the versioned data the operation returned and the exact reads it + * made to produce it. + * + * The filesystem is stubbed at `API.Fs` rather than with a shared helper + * because every read is evidence here: a source is read once by identity, a + * repository `.ts` component is never read at all, and nothing the document + * authored is ever written. Every effectful boundary a document could reach is + * installed as a refusal, so an execution that started anywhere would fail the + * run rather than pass unnoticed. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { API } from "@executablemd/runtime"; +import type { DirectoryEntry, LinkStatResult, StatResult } from "@executablemd/runtime"; + +import { + bodyStructureFacts, + renderBodyStructure, + validateBodyStructure, +} from "../src/body-structure.ts"; +import { parseMarkdownDefinition } from "../src/definition.ts"; +import { + documentValidationCodeRank, + inlineSource, + registerComponents, + validateDocument, +} from "../mod.ts"; +import type { + DocumentValidation, + DocumentValidationCode, + DocumentValidationDiagnostic, + InvocationOpacityReason, + InvocationSite, + InvocationValidation, + ValidateDocumentOptions, + ValidateDocumentSettings, +} from "../mod.ts"; +import type { IdentityComponent } from "../host.ts"; + +/** A stubbed tree: working-directory-relative path to file content. */ +type Tree = Record; + +const MISSING: StatResult = { exists: false, isFile: false, isDirectory: false }; + +/** + * What the run was allowed to do, and what it actually did. + * + * `reads` is appended in read order, so "once per source identity" and "never + * imported" are both read off the same list. `effects` records any refused + * boundary that fired at all — it stays empty, and a row that finds anything in + * it has found an execution. + */ +interface Probe { + readonly reads: string[]; + readonly effects: string[]; +} + +function probe(): Probe { + return { reads: [], effects: [] }; +} + +function resolve(path: string): string { + const segments = path.split("/").filter((segment) => segment !== "" && segment !== "."); + return segments.length === 0 ? "." : segments.join("/"); +} + +/** Paths whose read fails, so an unreadable source is a real trap. */ +const UNREADABLE = "Unreadable.md"; + +/** + * The contextual filesystem, and every other boundary a document could reach. + * + * Reading is the only thing validation may do, so reading is the only thing + * answered: every write, removal, enumeration, subprocess, fetch and eval + * compilation records itself and then throws. + */ +function* useEnvironment(tree: Tree, seen: Probe): Operation { + const refuse = (what: string) => { + seen.effects.push(what); + throw new Error(`validation reached ${what}`); + }; + yield* API.Fs.around({ + // deno-lint-ignore require-yield + *readTextFile([path]: [string]) { + seen.reads.push(resolve(path)); + if (resolve(path) === UNREADABLE) { + throw new Error(`EACCES: permission denied, open '${path}'`); + } + const content = tree[resolve(path)]; + if (content === undefined) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + return content; + }, + // deno-lint-ignore require-yield + *stat([path]: [string]): Operation { + const key = resolve(path); + if (key === UNREADABLE || tree[key] !== undefined) { + return { exists: true, isFile: true, isDirectory: false }; + } + return MISSING; + }, + // deno-lint-ignore require-yield + *lstat([path]: [string]): Operation { + const found = tree[resolve(path)] !== undefined; + return { exists: found, isFile: found, isDirectory: false, isSymbolicLink: false }; + }, + // deno-lint-ignore require-yield + *readDirectory([path]: [string]): Operation { + return refuse(`readDirectory(${path})`); + }, + // deno-lint-ignore require-yield + *glob(): Operation { + return refuse("glob"); + }, + // deno-lint-ignore require-yield + *writeTextFile([path]: [string, string]): Operation { + return refuse(`writeTextFile(${path})`); + }, + // deno-lint-ignore require-yield + *ensureDir([path]: [string]): Operation { + return refuse(`ensureDir(${path})`); + }, + // deno-lint-ignore require-yield + *rename([from]: [string, string]): Operation { + return refuse(`rename(${from})`); + }, + // deno-lint-ignore require-yield + *remove([path]: [string, unknown?]): Operation { + return refuse(`remove(${path})`); + }, + }); + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec(): Operation { + return refuse("exec"); + }, + }); + yield* API.Fetch.around({ + // deno-lint-ignore require-yield + *fetch(): Operation { + return refuse("fetch"); + }, + }); + yield* API.Env.around({ + // deno-lint-ignore require-yield + *compile(): Operation { + return refuse("compile"); + }, + }); +} + +interface Scenario extends ValidateDocumentSettings { + /** Files the contextual working directory holds. */ + readonly tree?: Tree; + /** Registrations installed in the calling scope, as a host would install. */ + readonly registrations?: readonly Parameters[0][number][]; +} + +/** Validate one supplied root against a scenario, and report what was reached. */ +function validating( + options: ValidateDocumentOptions, + scenario: Scenario = {}, +): Operation<{ result: DocumentValidation; seen: Probe }> { + return scoped(function* () { + const seen = probe(); + yield* useEnvironment(scenario.tree ?? {}, seen); + if (scenario.registrations !== undefined) { + yield* registerComponents(scenario.registrations); + } + const result = yield* validateDocument(options); + return { result, seen }; + }); +} + +/** Validate supplied text, which is the shape most rows use. */ +function validateText( + source: string, + scenario: Scenario = {}, + settings: ValidateDocumentSettings = {}, +): Operation<{ result: DocumentValidation; seen: Probe }> { + const { tree: _tree, registrations: _registrations, ...carried } = scenario; + return validating({ ...inlineSource(source), ...carried, ...settings }, scenario); +} + +function codes(result: DocumentValidation): DocumentValidationCode[] { + return result.diagnostics.map((diagnostic) => diagnostic.code); +} + +function names(result: DocumentValidation): string[] { + return result.invocations.map((invocation) => invocation.name); +} + +function outcomes(result: DocumentValidation): string[] { + return result.invocations.map((invocation) => invocation.outcome); +} + +function only(result: DocumentValidation): InvocationValidation { + expect(result.invocations).toHaveLength(1); + return result.invocations[0]!; +} + +function named(result: DocumentValidation, name: string): InvocationValidation { + const found = result.invocations.find((invocation) => invocation.name === name); + if (found === undefined) { + throw new Error(`no invocation named ${name} in [${names(result).join(", ")}]`); + } + return found; +} + +function diagnosticsOf( + result: DocumentValidation, + invocation: InvocationValidation, +): DocumentValidationDiagnostic[] { + if (invocation.outcome !== "invalid") { + throw new Error(`<${invocation.name} /> is ${invocation.outcome}, not invalid`); + } + return invocation.diagnosticIndexes.map((index) => result.diagnostics[index]!); +} + +/** An identity component whose factory ends the run if anything calls it. */ +function refusingIdentity(name: string, extra: Partial = {}): IdentityComponent { + return { + name, + origin: `test:${name}`, + props: { + type: "object", + properties: { label: { type: "string" } }, + required: ["label"], + additionalProperties: false, + }, + description: `Declares ${name}.`, + factory() { + throw new Error(`identity factory for ${name} was called`); + }, + ...extra, + } as IdentityComponent; +} + +const WIDGET = [ + "---", + "props:", + " title:", + " type: string", + "required: [title]", + "---", + "", + "Widget {props.title}", + "", +].join("\n"); + +describe("Tier DV: resolution and schemas", () => { + it("DV1: an unresolved name is invalid, has no origin, and starts nothing", function* () { + const { result, seen } = yield* validateText("\n"); + + expect(result.version).toBe(1); + expect(result.outcome).toBe("invalid"); + expect(codes(result)).toEqual(["component-unresolved"]); + expect(result.diagnostics[0]!.component).toBe("DefinitelyMissing"); + expect(result.diagnostics[0]!.message).toContain("Cannot resolve component: DefinitelyMissing"); + + const invocation = only(result); + expect(invocation.outcome).toBe("invalid"); + expect(invocation.outcome === "invalid" ? invocation.origin : "unreached").toBeUndefined(); + expect(invocation.outcome === "invalid" && invocation.diagnosticIndexes).toEqual([0]); + expect(seen.effects).toEqual([]); + }); + + it("DV2: a missing required prop on a registered component is a schema failure", function* () { + const { result, seen } = yield* validateText("\n"); + + expect(codes(result)).toEqual(["props-invalid"]); + const [diagnostic] = result.diagnostics; + expect(diagnostic!.component).toBe("File"); + expect(diagnostic!.issues).toEqual([ + { + instancePath: "", + schemaPath: "#/required", + keyword: "required", + params: { missingProperty: "path" }, + message: "must have required property 'path'", + }, + ]); + expect(diagnostic!.position?.line).toBe(1); + + const invocation = only(result); + expect(invocation.outcome).toBe("invalid"); + expect(invocation.origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: false, + }); + expect(seen.effects).toEqual([]); + }); + + it("DV3: structural, registered, declared and included components all pass", function* () { + const source = [ + "", + "held", + "", + "", + '', + "", + '', + "", + ].join("\n"); + + const { result, seen } = yield* validateText(source, { + tree: { "components/Widget.md": WIDGET }, + components: [refusingIdentity("Declared")], + includes: ["components", "."], + }); + + expect(result.diagnostics).toEqual([]); + expect(result.outcome).toBe("valid"); + expect(names(result)).toEqual(["If", "TempDir", "Declared", "Widget"]); + expect(outcomes(result)).toEqual(["valid", "valid", "valid", "valid"]); + expect(named(result, "If").origin).toEqual({ kind: "structural", construct: "If" }); + expect(named(result, "Declared").origin).toEqual({ + kind: "registered", + origin: "test:Declared", + reserved: false, + }); + expect(named(result, "Widget").origin).toEqual({ + kind: "repository", + path: "components/Widget.md", + }); + expect(seen.effects).toEqual([]); + }); +}); + +describe("Tier DV: recursive Markdown sources", () => { + const WRAPPER = ["", "", "", ""].join("\n"); + + it("DV4: a defect beneath control flow in a definition reports that source", function* () { + const { result, seen } = yield* validateText("\n", { + tree: { "components/Wrapper.md": WRAPPER }, + }); + + expect(codes(result)).toEqual(["component-unresolved"]); + const [diagnostic] = result.diagnostics; + expect(diagnostic!.position?.path).toBe("components/Wrapper.md"); + expect(diagnostic!.position?.line).toBe(2); + expect(names(result)).toEqual(["Wrapper", "If", "DefinitelyMissing"]); + expect(named(result, "Wrapper").outcome).toBe("valid"); + expect(named(result, "If").outcome).toBe("valid"); + expect(named(result, "DefinitelyMissing").outcome).toBe("invalid"); + // The branch is authored structure, not a decision: nothing evaluated + // `ready`, so no eval block was ever compiled. + expect(seen.effects).toEqual([]); + }); + + it("DV5: sources are root-first, then FIFO, read once, and a cycle terminates", function* () { + const tree = { + "components/Alpha.md": "\n\n\n", + "components/Beta.md": "\n", + "components/Shared.md": "shared\n", + }; + + const forward = yield* validateText("\n\n\n", { tree }); + expect(names(forward.result)).toEqual(["Alpha", "Beta", "Shared", "Alpha", "Shared"]); + expect(forward.result.outcome).toBe("valid"); + expect(forward.seen.reads.filter((path) => path.endsWith(".md")).sort()).toEqual([ + "components/Alpha.md", + "components/Beta.md", + "components/Shared.md", + ]); + + const reversed = yield* validateText("\n\n\n", { tree }); + expect(names(reversed.result)).toEqual(["Beta", "Alpha", "Shared", "Shared", "Alpha"]); + }); +}); + +describe("Tier DV: source, target and declaration failures", () => { + const ROWS: { + readonly id: string; + readonly source: string; + readonly target?: string; + readonly code: DocumentValidationCode; + }[] = [ + { id: "malformed source", source: "---\n: :\n---\n\nbody\n", code: "source-invalid" }, + { + id: "no matching target", + source: "# One\n\ntext\n", + target: "nothing-here", + code: "target-invalid", + }, + { + id: "ambiguous target", + source: "# Same\n\na\n\n# Same\n\nb\n", + target: "same", + code: "target-invalid", + }, + { + id: "malformed frontmatter", + source: "---\nwhen: 2020-01-01\n---\n\nbody\n", + code: "frontmatter-invalid", + }, + { + id: "props declaration", + source: "---\nprops: 5\n---\n\nbody\n", + code: "props-declaration-invalid", + }, + { + id: "returns declaration", + source: "---\nreturns: 5\n---\n\nbody\n", + code: "returns-declaration-invalid", + }, + ]; + + for (const row of ROWS) { + it(`DV6: a root ${row.id} maps to ${row.code} and invents no invocation`, function* () { + const { result } = yield* validating( + inlineSource(row.source, row.target === undefined ? {} : { target: row.target }), + ); + + expect(codes(result)).toEqual([row.code]); + expect(result.outcome).toBe("invalid"); + expect(result.invocations).toEqual([]); + expect(result.diagnostics[0]!.component).toBeUndefined(); + expect(result.diagnostics[0]!.position).toBeUndefined(); + }); + } + + it("DV6: an unreadable root is source-unreadable and carries no errno object", function* () { + const { result } = yield* validating({ path: UNREADABLE }); + + expect(codes(result)).toEqual(["source-unreadable"]); + expect(result.diagnostics[0]!.message).toBe(`Cannot read document source: ${UNREADABLE}`); + expect(result.invocations).toEqual([]); + }); + + it("DV6: a TypeScript root is an invalid document source", function* () { + const { result } = yield* validating({ path: "root.ts" }, { tree: { "root.ts": "export {}" } }); + + expect(codes(result)).toEqual(["source-invalid"]); + expect(result.invocations).toEqual([]); + }); + + it("DV6: every caller of a failed definition shares its one diagnostic", function* () { + const { result } = yield* validateText("\n\n\n", { + tree: { "components/Broken.md": "---\nprops: 5\n---\n\nbody\n" }, + }); + + expect(codes(result)).toEqual(["props-declaration-invalid"]); + expect(result.diagnostics[0]!.component).toBe("Broken"); + expect(result.diagnostics[0]!.message).toContain("components/Broken.md:"); + expect(outcomes(result)).toEqual(["invalid", "invalid"]); + for (const invocation of result.invocations) { + expect(invocation.outcome === "invalid" && invocation.diagnosticIndexes).toEqual([0]); + } + // No body sites are invented for a definition that could not be parsed. + expect(names(result)).toEqual(["Broken", "Broken"]); + }); + + it("DV6: an unreadable definition fails once and keeps its resolved origin", function* () { + const { result } = yield* validateText("\n", { includes: ["."] }); + + expect(codes(result)).toEqual(["source-unreadable"]); + const invocation = only(result); + expect(invocation.outcome).toBe("invalid"); + expect(invocation.origin).toEqual({ kind: "repository", path: "Unreadable.md" }); + }); +}); + +describe("Tier DV: static answers and opacity", () => { + it("DV7: a dynamic schema-visible prop is opaque and a capture is not", function* () { + const { result } = yield* validateText("\n\n\n"); + + expect(result.diagnostics).toEqual([]); + expect(result.outcome).toBe("valid"); + const file = named(result, "File"); + expect(file.outcome).toBe("not-statically-checkable"); + expect(file.outcome === "not-statically-checkable" && file.reasons).toEqual(["dynamic-props"]); + expect(named(result, "Json").outcome).toBe("valid"); + }); + + it("DV7: a definitely missing required key invalidates a partly dynamic invocation", function* () { + const { result } = yield* validateText("\n"); + + const invocation = only(result); + expect(invocation.outcome).toBe("invalid"); + const [diagnostic] = diagnosticsOf(result, invocation); + expect(diagnostic!.code).toBe("props-invalid"); + expect(diagnostic!.issues?.map((issue) => issue.params)).toEqual([{ missingProperty: "url" }]); + }); + + it("DV7: a mixed static and dynamic object gets no partial schema conclusion", function* () { + const { result } = yield* validateText('\n'); + + // `bogus` violates `additionalProperties: false`, and `url` is a value + // nothing here can resolve. Ajv is never asked, so nothing is claimed. + expect(result.diagnostics).toEqual([]); + const invocation = only(result); + expect(invocation.outcome).toBe("not-statically-checkable"); + expect(invocation.outcome === "not-statically-checkable" && invocation.reasons).toEqual([ + "dynamic-props", + ]); + }); + + it("DV8: a definite form failure wins over a dynamic prop, in code order", function* () { + const { result } = yield* validateText("\n"); + + const invocation = only(result); + expect(invocation.outcome).toBe("invalid"); + const found = diagnosticsOf(result, invocation); + expect(found.map((diagnostic) => diagnostic.code)).toEqual([ + "invocation-form-invalid", + "props-invalid", + ]); + expect(found[0]!.message).toContain("paired"); + expect(invocation.outcome === "invalid" && invocation.diagnosticIndexes).toEqual([0, 1]); + }); + + it("DV8: an independent body-shape defect wins over a dynamic prop", function* () { + const { result } = yield* validateText("\n", { + tree: { "components/Shape.md": "text\n\n\nx\n\n" }, + }); + + const invocation = named(result, "Shape"); + expect(invocation.outcome).toBe("invalid"); + expect(diagnosticsOf(result, invocation).map((diagnostic) => diagnostic.code)).toEqual([ + "body-shape-invalid", + ]); + }); + + it("DV9: an origin-only TypeScript component is never imported", function* () { + const { result, seen } = yield* validateText( + [ + "", + "", + "", + "", + "", + "", + "", + "", + ].join("\n"), + { tree: { "components/Native.ts": "export default function* () {}\n" } }, + ); + + expect(seen.reads).not.toContain("components/Native.ts"); + + const [plain, dynamic, captured, parent] = result.invocations.filter( + (invocation) => invocation.name === "Native", + ); + expect(plain!.outcome === "not-statically-checkable" && plain!.reasons).toEqual([ + "origin-only-contract", + ]); + expect(dynamic!.outcome === "not-statically-checkable" && dynamic!.reasons).toEqual([ + "dynamic-props", + "origin-only-contract", + ]); + expect(captured!.outcome).toBe("invalid"); + expect(diagnosticsOf(result, captured!).map((diagnostic) => diagnostic.code)).toEqual([ + "capture-invalid", + ]); + expect(parent!.outcome).toBe("not-statically-checkable"); + expect(parent!.origin).toEqual({ kind: "repository", path: "components/Native.ts" }); + + // Its children are the containing source's authored sites, and opacity + // does not spread to them. + expect(named(result, "DefinitelyMissing").outcome).toBe("invalid"); + }); +}); + +describe("Tier DV: root ownership and body contracts", () => { + it("DV10: root props are fully validated and traversal continues", function* () { + const source = [ + "---", + "props:", + " name:", + " type: string", + "required: [name]", + "---", + "", + "", + "", + ].join("\n"); + + const { result } = yield* validateText(source, {}, { props: {} }); + + expect(codes(result)).toEqual(["props-invalid", "component-unresolved"]); + const [rootProps] = result.diagnostics; + expect(rootProps!.component).toBeUndefined(); + expect(rootProps!.position).toBeUndefined(); + expect(rootProps!.issues?.map((issue) => issue.params)).toEqual([{ missingProperty: "name" }]); + expect(names(result)).toEqual(["DefinitelyMissing"]); + }); + + it("DV10: a root value body with no owns its own diagnostic", function* () { + const source = ["---", "returns:", " type: string", "---", "", "text\n"].join("\n"); + + const { result } = yield* validateText(source); + + expect(codes(result)).toEqual(["return-usage-invalid"]); + expect(result.diagnostics[0]!.component).toBeUndefined(); + expect(result.diagnostics[0]!.position).toBeUndefined(); + expect(result.invocations).toEqual([]); + }); + + it("DV10: a definition's body diagnostics keep its path and its authored position", function* () { + const { result } = yield* validateText("\n", { + tree: { "components/Shape.md": "text\n\n\nx\n\n" }, + }); + + expect(codes(result)).toEqual(["body-shape-invalid"]); + const [diagnostic] = result.diagnostics; + expect(diagnostic!.component).toBe("Output"); + expect(diagnostic!.position?.path).toBe("components/Shape.md"); + expect(diagnostic!.position?.line).toBe(4); + // The invocation of the definition and the `` itself both point at + // the one diagnostic the definition's own source produced. + expect(named(result, "Shape").outcome === "invalid" && named(result, "Shape").outcome).toBe( + "invalid", + ); + expect(named(result, "Output").outcome).toBe("invalid"); + }); + + it("DV10: a value component invoked without `as` is a return-usage failure", function* () { + const { result } = yield* validateText('\n'); + + const invocation = only(result); + expect(diagnosticsOf(result, invocation).map((diagnostic) => diagnostic.code)).toEqual([ + "return-usage-invalid", + ]); + }); +}); + +describe("Tier DV: ordering and determinism", () => { + const NESTED = [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + 'text', + "", + ].join("\n"); + + it("DV11: every record, including structural sites, is in source order", function* () { + const { result } = yield* validateText(NESTED); + + expect(names(result)).toEqual(["Loop", "If", "File", "Else", "Break", "Let"]); + const offsets = result.invocations.map((invocation) => invocation.position!.offset); + expect([...offsets].sort((left, right) => left - right)).toEqual(offsets); + expect(named(result, "Break").outcome).toBe("valid"); + }); + + it("DV11: repeated validation returns deep-equal diagnostics and invocations", function* () { + const scenario = { tree: { "components/Widget.md": WIDGET } }; + const source = "\n\n\n\n\n"; + + const first = yield* validateText(source, scenario); + const second = yield* validateText(source, scenario); + + expect(second.result).toEqual(first.result); + }); + + it("DV11: the closed code order is the one two diagnostics at a position use", function* () { + const order: DocumentValidationCode[] = [ + "source-unreadable", + "source-invalid", + "target-invalid", + "frontmatter-invalid", + "props-declaration-invalid", + "returns-declaration-invalid", + "component-unresolved", + "component-ambiguous", + "invocation-form-invalid", + "body-shape-invalid", + "props-invalid", + "binding-invalid", + "capture-invalid", + "return-usage-invalid", + "structural-usage-invalid", + ]; + + const ranks = order.map(documentValidationCodeRank); + expect(ranks).toEqual(order.map((_, index) => index)); + // The dormant code has a rank of its own: nothing reaches it while the + // shared selector always answers with exactly one component, and the sorter + // is ready for the day one can be ambiguous. + expect(documentValidationCodeRank("component-ambiguous")).toBe(7); + + // The sorter reads the same ranks, so a document whose failures land at one + // position reports them in this order rather than in discovery order. + const { result } = yield* validateText("\n"); + expect(codes(result)).toEqual(["invocation-form-invalid", "props-invalid"]); + }); +}); + +describe("Tier DV: one parser, one rule catalog", () => { + it("DV12: text the scanner treats as text produces no record", function* () { + const { result } = yield* validateText("a < b, and is prose.\n"); + + expect(result.invocations).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); + + it("DV12: an executable block is neither an invocation nor run", function* () { + const source = ["```sh exec", "echo hello", "```", ""].join("\n"); + + const { result, seen } = yield* validateText(source); + + expect(result.invocations).toEqual([]); + expect(result.diagnostics).toEqual([]); + expect(seen.effects).toEqual([]); + }); + + it("DV12: a spread keeps the meaning execution's scanner gives it", function* () { + const { result } = yield* validateText('\n'); + + // The scanner synthesizes no props from a spread, so validation must not + // turn one into a stricter parse error or an unknown-value marker. + expect(result.diagnostics).toEqual([]); + expect(only(result).outcome).toBe("valid"); + }); + + it("DV14: expansion's aggregate and validation's diagnostics read one catalog", function* () { + const body = "text\n\n\nx\n\n\n\n"; + const definition = yield* parseMarkdownDefinition("Shape", "components/Shape.md", body); + const facts = bodyStructureFacts(definition.bodySegments, definition.returns); + + // One walk, two renderings: expansion's one aggregate sentence, and one + // diagnostic per violation under its own code. + const aggregate = validateBodyStructure(definition.bodySegments, definition.returns); + expect(aggregate).toEqual(renderBodyStructure(facts)); + expect(aggregate!.message).toContain("Misplaced found"); + expect(aggregate!.message).toContain(" requires a document or component"); + + const { result } = yield* validateText("\n", { + tree: { "components/Shape.md": body }, + }); + expect(codes(result)).toEqual(["body-shape-invalid", "return-usage-invalid"]); + }); +}); + +describe("Tier DV: the no-execution boundary", () => { + const HOSTILE = [ + "---", + "props:", + " who:", + " type: string", + "---", + "", + '', + "", + "", + "", + 'contents', + "", + '', + "", + "```sh exec", + "rm -rf /", + "```", + "", + "```ts eval", + "globalThis.ran = true;", + "```", + "", + "go", + "", + '', + "", + "", + "", + ].join("\n"); + + const DEEP = [ + 'more', + "", + "```sh exec", + "date", + "```", + "", + ].join("\n"); + + it("DV13: every effectful boundary stays untouched and only sources are read", function* () { + const { result, seen } = yield* validateText(HOSTILE, { + tree: { "components/Deep.md": DEEP }, + components: [refusingIdentity("Declared")], + registrations: [ + { + name: "Exploding", + origin: "test:exploding", + props: { type: "object", properties: {}, additionalProperties: false }, + description: "Ends the run if it is ever invoked.", + // deno-lint-ignore require-yield + *fn() { + throw new Error("Exploding was invoked"); + }, + }, + ], + }); + + // Nothing was executed, compiled, spawned, fetched, elicited or written. + expect(seen.effects).toEqual([]); + // The only bytes read are the selected Markdown definition's: the root was + // supplied as text, and every candidate probe is a `stat`. + expect(seen.reads).toEqual(["components/Deep.md"]); + // The document is answered rather than run: `` and `` are + // registered by no host here, so they are unresolved names. + expect(codes(result)).toContain("component-unresolved"); + expect(named(result, "Exploding").outcome).toBe("valid"); + expect(named(result, "Declared").outcome).toBe("valid"); + expect(named(result, "Deep").outcome).toBe("valid"); + }); + + it("DV13: an invalid host declaration is a configuration error, not a diagnostic", function* () { + let raised: unknown; + try { + yield* validateText("text\n", { + components: [refusingIdentity("Twice"), refusingIdentity("Twice")], + }); + } catch (error) { + raised = error; + } + + expect(raised).toBeInstanceOf(Error); + expect((raised as Error).message).toContain("two identity components"); + }); +}); + +describe("Tier DV: the package boundary", () => { + it("DV15: the operation and every version-1 type are package-root exports", function* () { + const site: InvocationSite = { name: "File" }; + const reasons: readonly InvocationOpacityReason[] = ["dynamic-props", "origin-only-contract"]; + const settings: ValidateDocumentSettings = { props: {}, includes: ["components"] }; + const options: ValidateDocumentOptions = { ...inlineSource("text\n"), ...settings }; + const diagnostic: DocumentValidationDiagnostic = { + code: "component-unresolved", + message: "nothing here", + }; + + const { result } = yield* validating(options); + const answered: DocumentValidation = result; + const records: readonly InvocationValidation[] = answered.invocations; + + expect(typeof validateDocument).toBe("function"); + expect(site.name).toBe("File"); + expect(reasons).toHaveLength(2); + expect(diagnostic.code).toBe("component-unresolved"); + expect(answered.version).toBe(1); + expect(records).toEqual([]); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index b778a8c34..4450698fc 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2923,6 +2923,147 @@ and `--json`. It takes no document and no run option, because it runs nothing. An inspection failure is reported on stderr with exit status 1 and no partial catalog on stdout. +#### Validating a supplied document: `validateDocument()` + +Inspection answers about a name and about a directory. `validateDocument()` +answers about one **document**: is the supplied root, and the Markdown source +closure its authored invocations reach, a program at all — as far as anything +decidable without running it can say? A host that generated a document, or +received one, asks this before it asks a person to approve it. + +```typescript +type ValidateDocumentOptions = RootDocumentSource & { + readonly props?: Record; + readonly includes?: readonly string[]; + readonly components?: readonly IdentityComponent[]; +}; + +function* validateDocument( + options: ValidateDocumentOptions, +): Operation; +``` + +The environment is the declarative one `inspectSyntax()` reads and no more: +supplied text or a path with its optional target, root props as plain JSON, the +ordered includes, the contextual component registry, and the identity +declarations a host would make. There is no working-directory option — a +relative root or include resolves against the contextual runtime's, as execution +resolves it. A declaration set an execution would refuse is refused here too, +and it is the caller's configuration error rather than a diagnostic about the +document. + +**Validation follows source, not execution reachability.** The selected root +projection is scanned first; every Markdown definition ordinary selection +discovers enters one FIFO queue in the order its first invocation was +encountered, and a source identity is read and parsed exactly once. Every +authored invocation in each source is reported in source order, including one +written inside ``, `` or ``, and including children written +beneath an origin-only TypeScript component. No branch is evaluated and no +projection is predicted. A definition that invokes itself, directly or through +others, terminates the walk when the source identity comes back around, and that +cycle is not itself a failure. An invalid invocation still discovers and queues +the definition it selected. + +**A static answer never stands in for a runtime value.** Resolution, authored +form, engine-owned body shape and every other structural rule run whenever their +answer does not depend on evaluating document code. A required property is +reported missing when its authored key appears in neither the static props nor +the expression props, because presence needs no value. Full props-schema +validation runs only when every schema-visible prop is static; a declared +capture keeps the same schema bypass it has during execution and makes nothing +opaque. A dynamic schema-visible expression, or a repository `.ts` contract that +lives on the module's exports, makes an otherwise acceptable invocation not +statically checkable — validation solves no JSON Schema partially around the +value it does not have. A definite independent failure still wins, so an invalid +form does not become opaque because another prop is dynamic. + +**Parsing stays one language rule.** The scanner and the definition parsers are +execution's. Component-like text the scanner treats as text remains text, and a +spread attribute keeps its execution meaning: validation turns neither into a +stricter parse error nor into an unknown-value marker. Selection, declaration +admission, schemas, forms, body rules, targets and source positions come from +their execution definitions; where a rule had to be extracted from expansion, +both callers read the extracted rule rather than a second catalog. + +**The answer is versioned data.** + +```typescript +interface DocumentValidation { + readonly version: 1; + readonly outcome: "valid" | "invalid"; + readonly diagnostics: readonly DocumentValidationDiagnostic[]; + readonly invocations: readonly InvocationValidation[]; +} + +type InvocationValidation = { readonly name: string; readonly position?: SourcePosition } & ( + | { readonly outcome: "valid"; readonly origin: ComponentOrigin } + | { + readonly outcome: "invalid"; + readonly origin?: ComponentOrigin; + readonly diagnosticIndexes: readonly number[]; + } + | { + readonly outcome: "not-statically-checkable"; + readonly origin: ComponentOrigin; + readonly reasons: readonly ("dynamic-props" | "origin-only-contract")[]; + } +); + +interface DocumentValidationDiagnostic { + readonly code: DocumentValidationCode; + readonly message: string; + readonly position?: SourcePosition; + readonly component?: string; + readonly issues?: readonly NormalizedIssue[]; +} +``` + +The version-1 codes are closed, and their order in the union is part of the +contract: + +`source-unreadable`, `source-invalid`, `target-invalid`, `frontmatter-invalid`, +`props-declaration-invalid`, `returns-declaration-invalid`, +`component-unresolved`, `component-ambiguous`, `invocation-form-invalid`, +`body-shape-invalid`, `props-invalid`, `binding-invalid`, `capture-invalid`, +`return-usage-invalid`, `structural-usage-invalid`. + +A schema diagnostic carries the same normalized issues ordinary validation +produces, so nothing parses `message` to recover a property or a keyword. +`component-ambiguous` is dormant: the shared selector always answers with +exactly one component, and the code and its rank exist so that a selector which +one day answers otherwise has somewhere to say so. Neither multiple precedence +tiers, include order, nor a refused host declaration manufactures one. + +**Ordering is part of the answer.** The root is source ordinal zero and every +definition follows in FIFO discovery order. Within one source an unpositioned +source-level diagnostic precedes every positioned one; positioned diagnostics +sort by authored offset; two at one offset sort by the closed code order above. +An invocation's `diagnosticIndexes` are sorted, unique indexes into that one +array. Invocation records are in the same source order, at their opening +positions. Validating one document twice returns deep-equal results. + +**Ownership.** A root read or parse failure produces one diagnostic and no +invocation record at all. Root-props and root body-shape failures make the +document invalid without inventing a root invocation, and they do not stop the +root's body from being traversed; those diagnostics name no `component`. A +selected Markdown source that could not be read or parsed reports one diagnostic +for that source, every invocation selecting it is invalid pointing at that same +index, and no body sites are invented for it. A defect in a definition that +*did* parse belongs to that definition source, carries its path and authored +position, and does not stop the rest of that definition from being traversed. + +**The document outcome is `invalid` exactly when a diagnostic exists.** An +opaque invocation on its own leaves the document valid. + +**Observation performs no document effect.** Validation may read the supplied +root and the Markdown definition files selection identified. It evaluates no +expression, compiles no code block, invokes no component, imports no repository +`.ts` module, renders or projects no content, calls no identity factory, +installs no operational provider, mints no invocation, creates or reads no +journal, runs no command, prompts, elicits or starts no agent, and performs no +filesystem operation the document authored. That is one boundary rather than a +list of components: adding a component cannot make validation effectful. + Which implementation a name resolves to is an observation of the environment — which files exist, and what is registered — so it is made **inside** the durable @@ -9600,6 +9741,30 @@ so the include-boundary rows are the same on every host. Defined in §5.3. | SY28 | First-party documentation | Every complete built-in in the core and Agent profile states a description | | SY29 | Determinism | Two inspections of one environment are equal, and entries are sorted within each category | +### Tier DV — Document validation + +Provider-neutral: the contextual filesystem is stubbed at `API.Fs`, and every +other boundary a document could reach is installed as a refusal that records +itself, so an execution starting anywhere fails the row. Defined in §5.3. + +| # | Test | Verify | +|---|------|--------| +| DV1 | Unresolved name | Version 1, document `invalid`, one originless invalid invocation carrying `component-unresolved`, and nothing executed | +| DV2 | Missing required prop | `` reports `props-invalid` with the normalized required-property issue, keeps its registered origin, and never runs | +| DV3 | A valid document | Structural, core registered, declaration-only registered and included Markdown components together are `valid`, and the identity factory is never called | +| DV4 | A defect beneath control flow | A definition reached through an invocation reports its own path and authored position; the branch is never evaluated | +| DV5 | Traversal order | Root first, then FIFO discovery, one read per source identity, a cycle terminating; reversing the root's invocation order reverses only the FIFO portion | +| DV6 | Source, target and declarations | Unreadable and invalid root, no-match and ambiguous target, malformed frontmatter, props and returns declarations map to their exact codes and invent no invocation; a failed definition is one diagnostic every caller shares | +| DV7 | Static and dynamic props | A dynamic schema-visible prop is `not-statically-checkable`; a capture is not; a definitely missing required key still invalidates; a mixed object yields no partial schema conclusion | +| DV8 | Definite wins | A refused authored form, and an independent body-shape defect, each keep an invocation `invalid` beside a dynamic prop, with indexes in code order | +| DV9 | Origin-only TypeScript | Never imported, opaque with `origin-only-contract`, both reasons in canonical order with a dynamic prop, invalid on an engine-owned defect, and its authored children still validated | +| DV10 | Root and definition ownership | Root props validate fully without stopping traversal; root and definition body-shape and return diagnostics keep their source, position and component ownership | +| DV11 | Order and determinism | Every record including structural sites is in source order; two validations are deep-equal; the closed code order is a direct unit, `component-ambiguous` included | +| DV12 | One parser | Component-like text stays text, an executable block is neither a record nor run, and a spread keeps exactly the meaning execution's scanner gives it | +| DV13 | The no-execution seam | Identity factories, registered bodies, eval and exec, providers, agents, elicitation, journal and document-authored filesystem effects all stay at zero while only root and selected Markdown sources are read | +| DV14 | One rule catalog | Expansion's aggregate printed error and validation's per-violation diagnostics are two renderings of one extracted body-contract walk | +| DV15 | The package boundary | The operation and every version-1 type are `@executablemd/core` exports; no consumer reaches a private module | + ### Tier SX — The `xmd syntax` command | # | Test | Verify | From c028766939841ca1b4af43983ce80025d0de07b6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:08:44 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20Give=20one=20source=20identi?= =?UTF-8?q?ty=20one=20scan,=20and=20a=20structural=20fact=20its=20own=20re?= =?UTF-8?q?cord=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two version-1 contracts were wrong. The root did not enter the source cache, so a root living where a component name resolves was scanned twice: a retained root at `components/Foo.md` containing `` returned the invocation names `["Foo", "Foo"]` and read the file again. The root now registers under its own path, so a selection finding it terminates the walk the way any other cycle does and reads nothing. A structural fact a parent discovered about a descendant went to the parent alone. A misplaced `` found through its `` named `If`, and the `` it was about stayed valid. Such a finding now names and is positioned at the element it identifies, reaches that element's record, and still leaves the `` whose structure is malformed invalid. Preorder guarantees the parent is visited first, so the child picks it up rather than losing it. An `` with an unknown prop also returned early, hiding the missing condition and a malformed `` beside it. Every independent static check now runs. Expansion's first-refusal behavior is unchanged: it stops at the first violation over the same shared facts, and validation reads all of them. Each regression was confirmed discriminating by reverting its fix. --- packages/core/src/document-validation.ts | 61 +++++++++++--- .../core/tests/document-validation.test.ts | 81 +++++++++++++++++++ specs/executable-mdx-spec.md | 23 ++++-- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index a864bd3f6..b0396e9d5 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -350,6 +350,17 @@ class ValidationState { readonly #sources = new Map(); /** Parsed sources still waiting to have their own body walked. */ readonly #queue: ParsedSourceEntry[] = []; + /** + * Diagnostics a parent construct found about one of its descendants, waiting + * for the walk to reach that descendant. + * + * `` reads its own body to split it, so a misplaced `` beneath it + * is discovered before the walk arrives at the `` itself. The finding + * belongs to both — the ``'s structure is malformed, and the `` is + * the element that is wrong — and preorder guarantees the parent is visited + * first, so the child's record picks it up here rather than losing it. + */ + readonly #deferred = new Map(); #nextOrdinal = 0; #nextToken = 0; #sequence = 0; @@ -419,7 +430,12 @@ class ValidationState { }); return undefined; } - return this.#admitParsed(ordinal, parsed.value.definition); + // Under its own identity, like every other source: a root that lives where + // a component name resolves is one source, not two. A selection that finds + // it here terminates the walk the way any other cycle does, and reads + // nothing. A targeted root caches the projection it was asked about, which + // is the source validation is answering for. + return this.#admitParsed(ordinal, parsed.value.definition, path); } /** @@ -634,7 +650,10 @@ class ValidationState { const draft: DraftInvocation = { name: segment.name, ...(segment.position === undefined ? {} : { position: segment.position }), - tokens: [...(context.entry.elementTokens.get(segment) ?? [])], + tokens: [ + ...(context.entry.elementTokens.get(segment) ?? []), + ...(this.#deferred.get(segment) ?? []), + ], reasons: [], }; this.#invocations.push(draft); @@ -647,13 +666,19 @@ class ValidationState { if (selected.kind === "structural") { draft.origin = { kind: "structural", construct: selected.construct }; for (const violation of this.#structuralViolations(segment, context)) { - draft.tokens.push( - this.#draft(context.entry.ordinal, violation.code, { - message: violation.message, - component: segment.name, - ...positionOf(violation.element ?? segment), - }), - ); + // A violation names the construct it is about and, when the check + // walked past the element it was given, the element it is about. Both + // the diagnostic and the record that points at it follow that element. + const anchor = violation.element ?? segment; + const token = this.#draft(context.entry.ordinal, violation.code, { + message: violation.message, + component: violation.source, + ...positionOf(anchor), + }); + draft.tokens.push(token); + if (anchor !== segment) { + this.#defer(anchor, token); + } } return; } @@ -835,12 +860,14 @@ class ValidationState { case "Each": return eachViolations(segment); case "If": { + // Every one of these is decided from source alone, so every one of them + // is reported. Expansion stops at the first and expands neither branch, + // which is its own established behavior over the same shared facts — + // an author reading a validation result is owed all of them. const found: StructuralViolation[] = []; const unknownProp = ifPropsViolation(segment); if (unknownProp !== undefined) { - // Expansion stops here too: an `` whose props are wrong expands - // neither branch, and reads no structure it would then complain about. - return [unknownProp]; + found.push(unknownProp); } found.push(...ifStructure(segment).violations); const condition = ifConditionViolation(segment); @@ -900,6 +927,16 @@ class ValidationState { } } + /** Hold a parent's finding for the descendant record it belongs to. */ + #defer(element: ComponentElement, token: number): void { + const existing = this.#deferred.get(element); + if (existing === undefined) { + this.#deferred.set(element, [token]); + return; + } + existing.push(token); + } + #draft( sourceOrdinal: number, code: DocumentValidationCode, diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index af143b5ac..f5128af38 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -32,6 +32,7 @@ import { documentValidationCodeRank, inlineSource, registerComponents, + retainedSource, validateDocument, } from "../mod.ts"; import type { @@ -378,6 +379,86 @@ describe("Tier DV: recursive Markdown sources", () => { const reversed = yield* validateText("\n\n\n", { tree }); expect(names(reversed.result)).toEqual(["Beta", "Alpha", "Shared", "Shared", "Alpha"]); }); + + it("DV5: a root sharing a source identity with a component is scanned once", function* () { + const body = "\n"; + + // The root's text is retained rather than read, so a second scan of the + // same identity would show up as a second `Foo` record with nothing read. + const retained = yield* validating(retainedSource("components/Foo.md", body), { + tree: { "components/Foo.md": body }, + includes: ["components"], + }); + + expect(names(retained.result)).toEqual(["Foo"]); + expect(retained.result.outcome).toBe("valid"); + expect(named(retained.result, "Foo").origin).toEqual({ + kind: "repository", + path: "components/Foo.md", + }); + expect(retained.seen.reads).toEqual([]); + + // Read from a path, the same identity is read exactly once: the invocation + // finds the root already in the source cache. + const fromFile = yield* validating( + { path: "components/Foo.md" }, + { tree: { "components/Foo.md": body }, includes: ["components"] }, + ); + + expect(names(fromFile.result)).toEqual(["Foo"]); + expect(fromFile.seen.reads).toEqual(["components/Foo.md"]); + }); +}); + +describe("Tier DV: structural facts and the records that own them", () => { + it("DV4: a fact a parent discovered reaches the invocation it names", function* () { + const { result } = yield* validateText( + 'no\n', + ); + + expect(codes(result)).toEqual(["structural-usage-invalid"]); + const [diagnostic] = result.diagnostics; + expect(diagnostic!.component).toBe("Else"); + expect(diagnostic!.message).toContain(" must be a direct child of "); + // Positioned at the ``, not at the `` that read the body. + expect(diagnostic!.position?.offset).toBe(named(result, "Else").position?.offset); + + // The element that is wrong points at it, and so does the construct whose + // structure is malformed. The `` in between owns neither. + expect(named(result, "Else").outcome === "invalid" && named(result, "Else").outcome).toBe( + "invalid", + ); + expect(diagnosticsOf(result, named(result, "Else"))).toEqual([diagnostic]); + expect(diagnosticsOf(result, named(result, "If"))).toEqual([diagnostic]); + expect(named(result, "Let").outcome).toBe("valid"); + }); + + it("DV8: every independent static structural failure is reported", function* () { + const { result } = yield* validateText('\n'); + + expect(codes(result)).toEqual([ + "structural-usage-invalid", + "structural-usage-invalid", + "structural-usage-invalid", + ]); + expect(result.diagnostics.map((diagnostic) => diagnostic.component)).toEqual([ + "If", + "If", + "Else", + ]); + expect(result.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ + ' only accepts a "condition" prop. Got: "bogus".', + ' requires a "condition" prop.', + " must have content. Use ....", + ]); + + // The unknown prop does not hide the two failures beside it, and the + // `` failure belongs to the `` as well as to its ``. + expect(diagnosticsOf(result, named(result, "If"))).toHaveLength(3); + expect(diagnosticsOf(result, named(result, "Else")).map((found) => found.component)).toEqual([ + "Else", + ]); + }); }); describe("Tier DV: source, target and declaration failures", () => { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 4450698fc..77d9f9abf 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2962,7 +2962,10 @@ beneath an origin-only TypeScript component. No branch is evaluated and no projection is predicted. A definition that invokes itself, directly or through others, terminates the walk when the source identity comes back around, and that cycle is not itself a failure. An invalid invocation still discovers and queues -the definition it selected. +the definition it selected. The root is a source like any other and carries its +own path as its identity, so a root that lives where a component name resolves +is one source rather than two: an invocation selecting it finds it already +scanned, and reads nothing. **A static answer never stands in for a runtime value.** Resolution, authored form, engine-owned body shape and every other structural rule run whenever their @@ -2975,7 +2978,11 @@ opaque. A dynamic schema-visible expression, or a repository `.ts` contract that lives on the module's exports, makes an otherwise acceptable invocation not statically checkable — validation solves no JSON Schema partially around the value it does not have. A definite independent failure still wins, so an invalid -form does not become opaque because another prop is dynamic. +form does not become opaque because another prop is dynamic. Every check whose +answer is independent runs, and every one that fails is reported: where +expansion refuses a construct at its first violation and expands nothing +further, validation reads the whole of that construct's shared facts, because an +author reading a result is owed all of them at once. **Parsing stays one language rule.** The scanner and the definition parsers are execution's. Component-like text the scanner treats as text remains text, and a @@ -3052,6 +3059,12 @@ index, and no body sites are invented for it. A defect in a definition that *did* parse belongs to that definition source, carries its path and authored position, and does not stop the rest of that definition from being traversed. +A fact one construct discovered about a descendant belongs to both. `` reads +its own body to split it at ``, so a misplaced `` beneath it is +found before the walk arrives there: the diagnostic names and is positioned at +the ``, the ``'s own record points at it, and the `` — whose +structure is what is malformed — points at it too. + **The document outcome is `invalid` exactly when a diagnostic exists.** An opaque invocation on its own leaves the document valid. @@ -9752,11 +9765,11 @@ itself, so an execution starting anywhere fails the row. Defined in §5.3. | DV1 | Unresolved name | Version 1, document `invalid`, one originless invalid invocation carrying `component-unresolved`, and nothing executed | | DV2 | Missing required prop | `` reports `props-invalid` with the normalized required-property issue, keeps its registered origin, and never runs | | DV3 | A valid document | Structural, core registered, declaration-only registered and included Markdown components together are `valid`, and the identity factory is never called | -| DV4 | A defect beneath control flow | A definition reached through an invocation reports its own path and authored position; the branch is never evaluated | -| DV5 | Traversal order | Root first, then FIFO discovery, one read per source identity, a cycle terminating; reversing the root's invocation order reverses only the FIFO portion | +| DV4 | A defect beneath control flow | A definition reached through an invocation reports its own path and authored position; the branch is never evaluated; a fact a parent construct discovered names, positions at, and reaches the descendant it is about | +| DV5 | Traversal order | Root first, then FIFO discovery, one read per source identity, a cycle terminating; reversing the root's invocation order reverses only the FIFO portion; a root sharing an identity with a selected component is scanned once | | DV6 | Source, target and declarations | Unreadable and invalid root, no-match and ambiguous target, malformed frontmatter, props and returns declarations map to their exact codes and invent no invocation; a failed definition is one diagnostic every caller shares | | DV7 | Static and dynamic props | A dynamic schema-visible prop is `not-statically-checkable`; a capture is not; a definitely missing required key still invalidates; a mixed object yields no partial schema conclusion | -| DV8 | Definite wins | A refused authored form, and an independent body-shape defect, each keep an invocation `invalid` beside a dynamic prop, with indexes in code order | +| DV8 | Definite wins | A refused authored form, and an independent body-shape defect, each keep an invocation `invalid` beside a dynamic prop, with indexes in code order; every independent static structural failure is reported rather than only the first | | DV9 | Origin-only TypeScript | Never imported, opaque with `origin-only-contract`, both reasons in canonical order with a dynamic prop, invalid on an engine-owned defect, and its authored children still validated | | DV10 | Root and definition ownership | Root props validate fully without stopping traversal; root and definition body-shape and return diagnostics keep their source, position and component ownership | | DV11 | Order and determinism | Every record including structural sites is in source order; two validations are deep-equal; the closed code order is a direct unit, `component-ambiguous` included | From 1348aa307407477e55eb98e92f5677d56e8188f0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:19:38 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20Keep=20a=20targeted=20root's?= =?UTF-8?q?=20projection=20out=20of=20the=20definition=20a=20component=20s?= =?UTF-8?q?elected=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One path is bytes; what is parsed from them depends on who asked. Caching the root under its path conflated the two, so a targeted root's projection stood in for the full Markdown definition an ordinary component selection asks for — and a `` in the selected section returned `valid` while the sections the root never selected held a definite `body-shape-invalid` failure. Reading and parsing are now separate. A path is read once, whatever views the walk needs; the full-definition view is registered per path. An untargeted root's body is the whole file, so it registers as that view and a name resolving there finds it already scanned — the self-cycle deduplication is unchanged. A targeted root registers no view: a component selecting that path parses the full definition from the bytes already read and enters the FIFO queue in discovery order like any other source. Both halves of the regression were confirmed discriminating: substituting the projection again, and bypassing the content cache so the second view re-reads. --- packages/core/src/document-validation.ts | 68 +++++++++++++------ .../core/tests/document-validation.test.ts | 44 ++++++++++++ specs/executable-mdx-spec.md | 22 ++++-- 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index b0396e9d5..581f1698f 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -346,7 +346,25 @@ class ValidationState { readonly #registry: ComponentRegistry; readonly #diagnostics: DraftDiagnostic[] = []; readonly #invocations: DraftInvocation[] = []; - /** Source identity to what reading it produced. Read once, reused always. */ + /** + * One read per physical path, whatever is parsed from it. + * + * A path is bytes; what those bytes *mean* depends on who asked. Reading is + * shared here so that a file reaches the walk once however many views of it + * the document needs, and so that a retained root's supplied text is what a + * component selecting its path sees rather than whatever is on disk now. + */ + readonly #contents = new Map(); + /** + * One parsed view per path: the full Markdown definition an ordinary + * component selection of that path produces. + * + * A targeted root is deliberately absent from this map. Its body is a + * projection of one section, which is the view *it* was asked about and + * stands in for nothing else — a component selecting the same path is asking + * for the whole definition, and would be answered wrongly by a projection + * that omits the sections it never selected. + */ readonly #sources = new Map(); /** Parsed sources still waiting to have their own body walked. */ readonly #queue: ParsedSourceEntry[] = []; @@ -415,6 +433,8 @@ class ValidationState { this.#draft(ordinal, "source-unreadable", { message: unreadable(path), cause: error }); return undefined; } + // Supplied or read, these are this path's bytes for the rest of the walk. + this.#contents.set(path, content); const parsed = yield* parseRootMarkdownDefinitionPhased( ROOT_NAME, @@ -430,22 +450,28 @@ class ValidationState { }); return undefined; } - // Under its own identity, like every other source: a root that lives where - // a component name resolves is one source, not two. A selection that finds - // it here terminates the walk the way any other cycle does, and reads - // nothing. A targeted root caches the projection it was asked about, which - // is the source validation is answering for. - return this.#admitParsed(ordinal, parsed.value.definition, path); + // An untargeted root's body *is* the whole file, which is exactly the + // definition an ordinary component selection of this path parses — so it + // registers as that view, and a name resolving here finds it already + // scanned. A targeted root registers no view: it was asked about one + // section, and a component selecting this path is asking about the whole + // definition. Either way the bytes above are read once. + const view = options.target === undefined ? path : undefined; + return this.#admitParsed(ordinal, parsed.value.definition, view); } /** * The cache entry for a selected Markdown definition, reading and parsing it * on its first invocation and reusing that one answer afterwards. * - * The canonical selected path is the identity, so two names selecting one - * file share the entry, a definition that invokes itself finds itself here - * rather than recursing, and a file that could not be read carries one - * failure however many invocations selected it. + * The canonical selected path is the identity of this view, so two names + * selecting one file share the entry, a definition that invokes itself finds + * itself here rather than recursing, and a file that could not be read + * carries one failure however many invocations selected it. + * + * The bytes come from the shared content cache when something has already + * read them — a targeted root at this same path, most of all — so producing a + * second view of one file costs a parse and never a second read. */ *#loadSource(name: string, path: string): Operation { const cached = this.#sources.get(path); @@ -454,11 +480,14 @@ class ValidationState { } const ordinal = this.#nextOrdinal++; - let content: string; - try { - content = yield* readTextFile(path); - } catch (error) { - return this.#failSource(path, ordinal, "source-unreadable", unreadable(path), name, error); + let content = this.#contents.get(path); + if (content === undefined) { + try { + content = yield* readTextFile(path); + } catch (error) { + return this.#failSource(path, ordinal, "source-unreadable", unreadable(path), name, error); + } + this.#contents.set(path, content); } const parsed = yield* parseMarkdownDefinitionPhased(name, path, content); @@ -506,7 +535,8 @@ class ValidationState { #admitParsed( ordinal: number, definition: ComponentDefinition, - identity?: string, + /** The path this parse is the full-definition view of, when it is one. */ + view?: string, ): ParsedSourceEntry { const facts = bodyStructureFacts(definition.bodySegments, definition.returns); const elementTokens = new Map(); @@ -531,8 +561,8 @@ class ValidationState { bodyTokens, elementTokens, }; - if (identity !== undefined) { - this.#sources.set(identity, entry); + if (view !== undefined) { + this.#sources.set(view, entry); } return entry; } diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index f5128af38..137570937 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -380,6 +380,50 @@ describe("Tier DV: recursive Markdown sources", () => { expect(names(reversed.result)).toEqual(["Beta", "Alpha", "Shared", "Shared", "Alpha"]); }); + it("DV5: a targeted root and a component selecting its path are two views of one read", function* () { + const shared = [ + "# Chosen", + "", + "", + "", + "# Other", + "", + "", + "bad", + "", + "", + ].join("\n"); + + const { result, seen } = yield* validating( + retainedSource("components/Foo.md", shared, { target: "Chosen" }), + { tree: { "components/Foo.md": shared }, includes: ["components"] }, + ); + + // The root was asked about one section, and its projection is clean. The + // `` written there selects the *whole* definition, whose body puts + // an `` where the contract does not allow one. + expect(codes(result)).toEqual(["body-shape-invalid"]); + const [diagnostic] = result.diagnostics; + expect(diagnostic!.component).toBe("Output"); + expect(diagnostic!.position?.path).toBe("components/Foo.md"); + expect(result.outcome).toBe("invalid"); + + // Both `` sites — the one in the projection and the one in the full + // definition — are invalid against that one diagnostic. + for (const invocation of result.invocations.filter((found) => found.name === "Foo")) { + expect(diagnosticsOf(result, invocation)).toEqual([diagnostic]); + } + // The projection is source zero and the full definition follows it in FIFO + // order; selecting the path a second time reuses that view rather than + // scanning it again. + expect(names(result)).toEqual(["Foo", "Foo", "If", "Output"]); + + // One read, two views: the retained bytes answered both parses, and nothing + // the document wrote was executed to find any of this out. + expect(seen.reads).toEqual([]); + expect(seen.effects).toEqual([]); + }); + it("DV5: a root sharing a source identity with a component is scanned once", function* () { const body = "\n"; diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 77d9f9abf..514044722 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2962,10 +2962,20 @@ beneath an origin-only TypeScript component. No branch is evaluated and no projection is predicted. A definition that invokes itself, directly or through others, terminates the walk when the source identity comes back around, and that cycle is not itself a failure. An invalid invocation still discovers and queues -the definition it selected. The root is a source like any other and carries its -own path as its identity, so a root that lives where a component name resolves -is one source rather than two: an invocation selecting it finds it already -scanned, and reads nothing. +the definition it selected. + +**One read per path, one view per role.** Bytes and meaning are separate: a path +is read once however many times the walk needs it, while what is *parsed* from +those bytes depends on who asked. An ordinary component selection carries no +target and asks for the whole Markdown definition. A root asks for the +projection its own selector named. An untargeted root's body is the whole file, +so the two questions have one answer and a component resolving to that path +finds it already scanned — which is how a root that invokes itself terminates. +A **targeted** root's body is one section, and it stands in for nothing: a +component selecting the same path gets its own full-definition view, parsed from +the bytes already read, entering the FIFO queue in discovery order like any +other source. Substituting the projection there would hide every failure in the +sections the root never selected. **A static answer never stands in for a runtime value.** Resolution, authored form, engine-owned body shape and every other structural rule run whenever their @@ -9766,12 +9776,12 @@ itself, so an execution starting anywhere fails the row. Defined in §5.3. | DV2 | Missing required prop | `` reports `props-invalid` with the normalized required-property issue, keeps its registered origin, and never runs | | DV3 | A valid document | Structural, core registered, declaration-only registered and included Markdown components together are `valid`, and the identity factory is never called | | DV4 | A defect beneath control flow | A definition reached through an invocation reports its own path and authored position; the branch is never evaluated; a fact a parent construct discovered names, positions at, and reaches the descendant it is about | -| DV5 | Traversal order | Root first, then FIFO discovery, one read per source identity, a cycle terminating; reversing the root's invocation order reverses only the FIFO portion; a root sharing an identity with a selected component is scanned once | +| DV5 | Traversal order | Root first, then FIFO discovery, one read per path, a cycle terminating; reversing the root's invocation order reverses only the FIFO portion; an untargeted root sharing a path with a selected component is scanned once, while a targeted root's projection and that path's full definition are two views of one read | | DV6 | Source, target and declarations | Unreadable and invalid root, no-match and ambiguous target, malformed frontmatter, props and returns declarations map to their exact codes and invent no invocation; a failed definition is one diagnostic every caller shares | | DV7 | Static and dynamic props | A dynamic schema-visible prop is `not-statically-checkable`; a capture is not; a definitely missing required key still invalidates; a mixed object yields no partial schema conclusion | | DV8 | Definite wins | A refused authored form, and an independent body-shape defect, each keep an invocation `invalid` beside a dynamic prop, with indexes in code order; every independent static structural failure is reported rather than only the first | | DV9 | Origin-only TypeScript | Never imported, opaque with `origin-only-contract`, both reasons in canonical order with a dynamic prop, invalid on an engine-owned defect, and its authored children still validated | -| DV10 | Root and definition ownership | Root props validate fully without stopping traversal; root and definition body-shape and return diagnostics keep their source, position and component ownership | +| DV10 | Root and definition ownership | Root props validate fully without stopping traversal; root and definition body-shape and return diagnostics keep their source, position and component ownership; a projection's clean body never answers for the full definition a component selected | | DV11 | Order and determinism | Every record including structural sites is in source order; two validations are deep-equal; the closed code order is a direct unit, `component-ambiguous` included | | DV12 | One parser | Component-like text stays text, an executable block is neither a record nor run, and a spread keeps exactly the meaning execution's scanner gives it | | DV13 | The no-execution seam | Identity factories, registered bodies, eval and exec, providers, agents, elicitation, journal and document-authored filesystem effects all stay at zero while only root and selected Markdown sources are read | From 446c9bede7b42723374dec02f2e8a0ed6164298d Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:38:40 -0400 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20Name=20the=20delivery=20PR?= =?UTF-8?q?=20in=20the=20construct=20inventory=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture.md b/architecture.md index 4b8714540..0ed85a205 100644 --- a/architecture.md +++ b/architecture.md @@ -3510,7 +3510,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-1 JSON, from one catalog. 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 | built on the #632 stack | -| 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 #653 stack | +| 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 | | `` / `printErrors(fn)` | prints failures | built on main | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | | `` | renders one supplied value as JSON text where the element was written, from one native two-space `JSON.stringify` call. An ordinary overridable core default whose operand is a capture: the exact evaluation result arrives by reference and is never mutated, cloned, replaced or frozen. It binds nothing, and `as`, content and a missing `value` are all refused before the operand evaluates. A value with no JSON text and a serialization that threw are distinct failures, each positioned at the invocation, emitting no partial output and preserving the original error as its cause. No scope, resource, authority or JSON-specific durable effect: replay reaches it through ordinary expansion, and a surrounding `` or `` keeps its own record of the text it consumed | built on the #452 stack | From bd7beeb6d89a03737c0860c722d216f305e018fb Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:46:27 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=9A=91=20Sort=20with=20what=20the=20N?= =?UTF-8?q?ode=20lib=20provides=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.prototype.toSorted` is ES2023 and `tsconfig.node.json` targets ES2022, so every `test-node` shard failed at its typecheck before running a test. Deno's own lib is newer, which is why `deno check` and the whole local suite were green. Both call sites already build a fresh array, so sorting in place mutates nothing anyone else holds. That reinstates an oxlint `no-array-sort` warning — the shape the rest of the repository is in, and a warning rather than the error this was. Verified with the check CI runs: `tsc --project tsconfig.node.json --noEmit`, plus the validation suite under all three runtimes. --- packages/core/src/document-validation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 581f1698f..f50eee31b 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -1003,7 +1003,7 @@ class ValidationState { * the stable tie breaker and is never public. */ finish(): DocumentValidation { - const sorted = this.#diagnostics.toSorted(compareDrafts); + const sorted = [...this.#diagnostics].sort(compareDrafts); const indexes = new Map(); sorted.forEach((draft, index) => indexes.set(draft.token, index)); @@ -1023,7 +1023,7 @@ class ValidationState { if (draft.tokens.length > 0) { const diagnosticIndexes = [ ...new Set(draft.tokens.map((token) => indexes.get(token)!)), - ].toSorted((left, right) => left - right); + ].sort((left, right) => left - right); return { ...site, outcome: "invalid", From f3496876c4d978982e607cc7ab8309fcce476b86 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:03:32 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Finish=20the=20extract?= =?UTF-8?q?ion,=20and=20say=20what=20a=20source=20view=20is=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation still reimplemented four authored decisions expansion owns: an ordinary component's `as` form and binding name, the requirement that a component declaring `returns` is invoked with `as`, and the `` and `` prop, delegate, body-shape, template-form and required-value rules. Sharing the sentences was not sharing the decisions. New `invocation-rules.ts` owns the ordinary-component rules, and the `` and `` rules in `structural-rules.ts` split into the phases their callers ask in. Extraction is phase-appropriate rather than phase-bound: whether `as` was written as an expression is answered from the scanned element, while what a binding name may be is answered from whatever value each caller has — expansion passes the prop it resolved, validation passes the literal the author wrote. Expansion refuses at the first of them, in the phase it always did, with byte-identical prose; validation aggregates every independent one. The regressions run each document twice — once through `execute()`, once through `validateDocument()` — and require one sentence from both. Each was confirmed discriminating by giving expansion back a private copy of the rule. The source/view contract is also corrected everywhere it was stated. Bytes are read once per path; each semantic view of them is parsed and scanned once. An untargeted root is that path's full-definition view as well. A targeted root's projection and a component-selected full definition are two distinct views of one path. The claim that a source identity is parsed or scanned once is gone from architecture.md, the spec, the public comments and the tests. --- architecture.md | 25 ++- packages/core/src/answers.ts | 69 ++++----- packages/core/src/document-validation.ts | 130 ++++++++++------ packages/core/src/expand.ts | 82 ++++------ packages/core/src/invocation-rules.ts | 86 +++++++++++ packages/core/src/structural-rules.ts | 113 ++++++++++---- .../core/tests/document-validation.test.ts | 142 +++++++++++++++++- specs/executable-mdx-spec.md | 44 +++--- 8 files changed, 488 insertions(+), 203 deletions(-) create mode 100644 packages/core/src/invocation-rules.ts diff --git a/architecture.md b/architecture.md index 0ed85a205..fc76800fa 100644 --- a/architecture.md +++ b/architecture.md @@ -3443,13 +3443,24 @@ provider or reconstructs a CLI host to learn what names mean. target selection to the root, scans that selected projection, and follows normal component selection through every resolved Markdown definition the authored invocations discover. The root is first; newly discovered definitions enter one -FIFO queue in their first-invocation order, and a source identity is scanned only -once. Every invocation in each source is checked in source order, including one -inside authored control flow and children beneath an origin-only TypeScript -component. The validator neither evaluates a branch nor predicts whether a -component will project its children. A definition cycle therefore terminates -the source walk and is not itself a validation failure unless a shared authored -rule already makes it one. +FIFO queue in their first-invocation order. Every invocation in each source is +checked in source order, including one inside authored control flow and children +beneath an origin-only TypeScript component. The validator neither evaluates a +branch nor predicts whether a component will project its children. A definition +cycle therefore terminates the source walk and is not itself a validation +failure unless a shared authored rule already makes it one. + +**A path's bytes are read once; each view of them is parsed and scanned once.** +Reading and interpreting are separate, because one file can owe the walk more +than one answer. An ordinary component selection carries no target and asks for +the whole Markdown definition. A root asks for the projection its own selector +named. An untargeted root's body *is* the whole file, so it is that path's +full-definition view as well, and a name resolving there finds it already +scanned — which is what makes a root invoking itself terminate. A targeted root +projection and the full definition a component selected are two distinct views +of one path, each parsed and scanned once, from bytes read once. Letting a +projection stand in for the definition would answer for sections the root never +selected. **A static answer never stands in for a runtime value.** Resolution, authored form, engine-owned body shape and other structural rules run whenever their diff --git a/packages/core/src/answers.ts b/packages/core/src/answers.ts index 547789618..7d57c24de 100644 --- a/packages/core/src/answers.ts +++ b/packages/core/src/answers.ts @@ -89,15 +89,13 @@ import type { ParsedTemplate } from "./template.ts"; import type { ComponentElement, ErrorSegment, Json, Segment } from "./types.ts"; import type { AnswersPlacement, DeclarationScanner } from "./declaration-scan.ts"; import { - answerMissingValueMessage, - answerPropMessage, + answerFormViolations, + answersBodyViolation, answersDelegateMessage, - answersLiteralDelegateMessage, - answersNoBodyMessage, - answersPropMessage, - answerTemplateBothMessage, - answerTemplateExpressionMessage, + answersPropNameViolations, + answerValueViolation, isBlankText as isBlankSegment, + literalDelegate, strayAnswerMessage, } from "./structural-rules.ts"; @@ -230,10 +228,9 @@ export function* expandAnswers( /** The region the answered body renders into. */ owner: Segment[], ): Operation { - for (const name of Object.keys({ ...element.props, ...element.expressions })) { - if (name !== "delegate") { - return [yield* raise(violationError(answersPropMessage(name), ANSWERS, element))]; - } + const propRefusal = answersPropNameViolations(element)[0]; + if (propRefusal !== undefined) { + return [yield* raise(violationError(propRefusal.message, ANSWERS, element))]; } const delegate = yield* readDelegate(element); if (delegate.error) { @@ -249,8 +246,9 @@ export function* expandAnswers( } const { matchers, body } = partitioned; - if (element.selfClosing || body.every(isBlankSegment)) { - return [yield* raise(violationError(answersNoBodyMessage(), ANSWERS, element))]; + const bodyRefusal = answersBodyViolation(element); + if (bodyRefusal !== undefined) { + return [yield* raise(violationError(bodyRefusal.message, ANSWERS, element))]; } const bindings = (yield* env)?.values ?? {}; @@ -310,10 +308,9 @@ function* readChildAnswers( element: ComponentElement, expand: ExpandSegments, ): Operation> { - for (const name of Object.keys({ ...element.props, ...element.expressions })) { - if (name !== "delegate") { - return Err(new Error(positioned(answersPropMessage(name), element))); - } + const propRefusal = answersPropNameViolations(element)[0]; + if (propRefusal !== undefined) { + return Err(new Error(positioned(propRefusal.message, element))); } const delegate = yield* readDelegate(element); if (delegate.error) { @@ -514,25 +511,18 @@ function* readAnswer( expand: ExpandSegments, index: number, ): Operation { - for (const name of Object.keys({ ...element.props, ...element.expressions })) { - if (name !== "template" && name !== "value") { - return refuseAnswer(element, answerPropMessage(name)); - } - } - - // An expression template is never read, and silently produces a matcher with - // no template — which first-wins plus reusable turns into permanent shadowing - // of everything below it. `` reaches its "requires a template" - // error by the same route; this says so directly. - if ("template" in element.expressions) { - return refuseAnswer(element, answerTemplateExpressionMessage()); + // Everything about the matcher's *shape* is decided from what was written: + // which props it carries, and whether its template was written as an + // expression — which is never read, and would silently produce a matcher with + // no template, which first-wins plus reusable turns into permanent shadowing + // of everything below it — or written twice. + const formRefusal = answerFormViolations(element)[0]; + if (formRefusal !== undefined) { + return refuseAnswer(element, formRefusal.message); } const templateProp = element.props.template; const hasChildren = !element.selfClosing && element.children.length > 0; - if (typeof templateProp === "string" && hasChildren) { - return refuseAnswer(element, answerTemplateBothMessage()); - } let template: ParsedTemplate | undefined; const source = @@ -555,8 +545,9 @@ function* readAnswer( template = parsed.value; } - if (!("value" in element.props) && !("value" in element.expressions)) { - return refuseAnswer(element, answerMissingValueMessage()); + const valueRefusal = answerValueViolation(element); + if (valueRefusal !== undefined) { + return refuseAnswer(element, valueRefusal.message); } const value = yield* readValue(element); if (value.error) { @@ -650,12 +641,6 @@ function* readDelegate(element: ComponentElement): Operation<{ value: boolean; e } return { value: evaluated }; } - if (!("delegate" in element.props)) { - return { value: false }; - } - const raw = element.props.delegate; - if (typeof raw !== "boolean") { - return { value: false, error: answersLiteralDelegateMessage(raw) }; - } - return { value: raw }; + const literal = literalDelegate(element); + return literal.ok ? { value: literal.value } : { value: false, error: literal.error.message }; } diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index f50eee31b..4ce1de71e 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -45,8 +45,13 @@ import { } from "./definition.ts"; import type { DefinitionPhase } from "./definition.ts"; import { assertDistinctIdentityNames } from "./invocation-identity.ts"; +import { + asBindingViolation, + asExpressionViolation, + capturedBinding, + returnCaptureViolation, +} from "./invocation-rules.ts"; import type { IdentityComponent } from "./invocation-identity.ts"; -import { validateBindingName } from "./live-env.ts"; import { readRootSource, rootSourcePath } from "./root-source.ts"; import type { RootDocumentSource } from "./root-source.ts"; import { @@ -292,6 +297,18 @@ interface LexicalContext { readonly underAnswers: boolean; } +/** + * What one invocation site captures, as far as its own `as` decided. + * + * `refused` is not the same as capturing nothing: a site that wrote an `as` the + * engine rejected has already been told so, and asking the return contract + * about it again would report one mistake as two. + */ +interface AuthoredCapture { + readonly refused: boolean; + readonly binding?: string; +} + /** What a complete contract states about one invocation. */ interface CompleteContract { readonly props: PropsSchema; @@ -306,11 +323,13 @@ interface CompleteContract { * * Traversal follows source rather than execution: the selected root projection * first, then each discovered definition in the order its first invocation was - * encountered, each source scanned exactly once. An invocation written inside - * ``, `` or `` is authored program structure and is checked - * like any other; no branch is evaluated to decide whether it would run. A - * definition that invokes itself, directly or through others, terminates the - * walk when the source identity comes back around, and that is not a failure. + * encountered. A path's bytes are read once and each view of them — the + * projection a targeted root asked about, the full definition a component + * selection asks about — is parsed and scanned once. An invocation written + * inside ``, `` or `` is authored program structure and is + * checked like any other; no branch is evaluated to decide whether it would + * run. A definition that invokes itself, directly or through others, terminates + * the walk when that view comes back around, and that is not a failure. * * The result is deterministic: the same document and environment produce * deep-equal diagnostics and invocation records every time. @@ -735,27 +754,50 @@ class ValidationState { ); } - // Engine-owned and independent of any contract: `as` names a binding, and - // whether the author wrote a name at all is decided from the syntax. - const captureViolation = componentCaptureViolation(segment); - if (captureViolation !== undefined) { + // Engine-owned and independent of any contract, so the same two rules + // expansion applies decide it here: how `as` was written, and what it may + // name. Expansion asks the second about the value it resolved; this asks it + // about the literal the author wrote, which is the value this phase has. + const captureViolations: StructuralViolation[] = []; + const asExpression = asExpressionViolation(segment.name, segment.expressions); + if (asExpression !== undefined) { + captureViolations.push(asExpression); + } + const asRefused = asBindingViolation(segment.name, segment.props.as); + if (asRefused !== undefined) { + captureViolations.push(asRefused); + } + for (const violation of captureViolations) { draft.tokens.push( - this.#draft(context.entry.ordinal, "capture-invalid", { - message: captureViolation, - component: segment.name, + this.#draft(context.entry.ordinal, violation.code, { + message: violation.message, + component: violation.source, ...positionOf(segment), }), ); } + // What this site captures, for the contract checks below. An `as` already + // refused names nothing, and saying so a second time would report one + // mistake twice. + const capture: AuthoredCapture = + captureViolations.length > 0 + ? { refused: true } + : { refused: false, binding: capturedBinding(segment.props.as) }; if (selected.kind === "registered") { draft.origin = selected.origin; - yield* this.#checkContract(segment, context, draft, { - props: selected.definition.props, - captures: selected.definition.captures ?? [], - forms: selected.definition.forms ?? BOTH_FORMS, - hasReturns: selected.definition.returns !== undefined, - }); + yield* this.#checkContract( + segment, + context, + draft, + { + props: selected.definition.props, + captures: selected.definition.captures ?? [], + forms: selected.definition.forms ?? BOTH_FORMS, + hasReturns: selected.definition.returns !== undefined, + }, + capture, + ); return; } @@ -786,12 +828,18 @@ class ValidationState { // A definition whose own body contract is broken is broken for every // caller, at the position its own source states. draft.tokens.push(...source.bodyTokens); - yield* this.#checkContract(segment, context, draft, { - props: source.definition.props, - captures: [], - forms: BOTH_FORMS, - hasReturns: source.definition.returns !== undefined, - }); + yield* this.#checkContract( + segment, + context, + draft, + { + props: source.definition.props, + captures: [], + forms: BOTH_FORMS, + hasReturns: source.definition.returns !== undefined, + }, + capture, + ); } /** @@ -803,6 +851,7 @@ class ValidationState { context: LexicalContext, draft: DraftInvocation, contract: CompleteContract, + capture: AuthoredCapture, ): Operation { const ordinal = context.entry.ordinal; const form: InvocationForm = segment.selfClosing ? "self-closing" : "paired"; @@ -818,13 +867,16 @@ class ValidationState { ); } - if (contract.hasReturns && !("as" in segment.props) && !("as" in segment.expressions)) { + // The same rule expansion applies, asked about the capture this site names. + // A site whose `as` was already refused is not asked again. + const missingCapture = capture.refused + ? undefined + : returnCaptureViolation(segment.name, contract.hasReturns, capture.binding); + if (missingCapture !== undefined) { draft.tokens.push( - this.#draft(ordinal, "return-usage-invalid", { - message: - `<${segment.name} /> declares \`returns\`, so it renders nothing and must be ` + - `invoked with \`as\`: <${segment.name} as="binding" />.`, - component: segment.name, + this.#draft(ordinal, missingCapture.code, { + message: missingCapture.message, + component: missingCapture.source, ...positionOf(segment), }), ); @@ -1144,22 +1196,6 @@ function missingRequired( return issues; } -/** - * What an `as` that cannot name a binding says, or `undefined` when it can. - * - * Decided on the authored text rather than a resolved value, exactly as - * expansion decides it: evaluating it first would make the outcome depend on - * the host, because a bare identifier that happens to name a global resolves on - * one runtime and throws on another. - */ -function componentCaptureViolation(segment: ComponentElement): string | undefined { - if ("as" in segment.expressions) { - return `Prop "as" on <${segment.name} /> must be a string literal.`; - } - const binding = validateBindingName(segment.props.as); - return binding.ok ? undefined : `Prop "as" on <${segment.name} /> ${binding.error.message}`; -} - /** The code one parsing phase's failure is reported under. */ function codeForPhase(phase: DefinitionPhase): DocumentValidationCode { switch (phase) { diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index f370fdb18..a3b813814 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -58,6 +58,12 @@ import { strayStructuralMessage, } from "./structural-rules.ts"; import type { StructuralViolation } from "./structural-rules.ts"; +import { + asBindingViolation, + asExpressionViolation, + capturedBinding, + returnCaptureViolation, +} from "./invocation-rules.ts"; import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; import { @@ -121,7 +127,7 @@ import { import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; -import { liveEnvironment, validateBindingName } from "./live-env.ts"; +import { liveEnvironment } from "./live-env.ts"; import { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { TestHarnessBinding } from "./test-harness.ts"; @@ -2187,18 +2193,9 @@ function* expandComponent( return [yield* raise(placementError)]; } - // `as` names a binding, so it is rejected on the expression itself rather - // than on a resolved value. Evaluating it first would make the outcome - // depend on the host: a bare identifier that happens to name a global - // resolves on one runtime and throws ReferenceError on another. - if ("as" in expressions) { - return [ - yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }), - ]; + const asExpression = asExpressionViolation(name, expressions); + if (asExpression !== undefined) { + return [yield* raise({ type: "error", message: asExpression.message, source: name })]; } // Resolve eval expression props against env.values using the shared @@ -2223,11 +2220,11 @@ function* expandComponent( let validatedProps: Record; let asBinding: string | undefined; try { - const binding = validateBindingName(resolvedProps.as); - if (!binding.ok) { - throw new Error(`Prop "as" on <${name} /> ${binding.error.message}`); + const refused = asBindingViolation(name, resolvedProps.as); + if (refused !== undefined) { + throw new Error(refused.message); } - asBinding = binding.value; + asBinding = capturedBinding(resolvedProps.as); const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; validatedProps = yield* validateProps(name, propsForValidation, definition.props); @@ -2235,16 +2232,9 @@ function* expandComponent( return [yield* raise(schemaValidationErrorSegment(error, name))]; } - if (definition.returns !== undefined && asBinding === undefined) { - return [ - yield* raise({ - type: "error", - message: - `<${name} /> declares \`returns\`, so it renders nothing and must be invoked ` + - `with \`as\`: <${name} as="binding" />.`, - source: name, - }), - ]; + const missingCapture = returnCaptureViolation(name, definition.returns !== undefined, asBinding); + if (missingCapture !== undefined) { + return [yield* raise({ type: "error", message: missingCapture.message, source: name })]; } // Capture the caller's eval environment before creating the component's @@ -2628,14 +2618,9 @@ function* expandFunctionComponent( // name, is selected ahead of core's default: a different definition runs and // inherits the ordinary disposition. const checkedFailures = definition.fn === CoreTest ? containedLedger(inherited) : inherited; - if ("as" in expressions) { - return [ - yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }), - ]; + const asExpression = asExpressionViolation(name, expressions); + if (asExpression !== undefined) { + return [yield* raise({ type: "error", message: asExpression.message, source: name })]; } // Captures are the engine's to hand over unresolved, like `slot` and `as` are @@ -2688,17 +2673,11 @@ function* expandFunctionComponent( } // Strip slot prop before validation - const asBindingResult = validateBindingName(resolvedProps.as); - if (!asBindingResult.ok) { - return [ - yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> ${asBindingResult.error.message}`, - source: name, - }), - ]; + const asRefused = asBindingViolation(name, resolvedProps.as); + if (asRefused !== undefined) { + return [yield* raise({ type: "error", message: asRefused.message, source: name })]; } - const asBinding = asBindingResult.value; + const asBinding = capturedBinding(resolvedProps.as); const owner = asBinding === undefined ? callerOwner : undefined; const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; @@ -2711,16 +2690,9 @@ function* expandFunctionComponent( } const returns = definition.returns; - if (returns !== undefined && asBinding === undefined) { - return [ - yield* raise({ - type: "error", - message: - `<${name} /> declares \`returns\`, so it renders nothing and must be invoked ` + - `with \`as\`: <${name} as="binding" />.`, - source: name, - }), - ]; + const missingCapture = returnCaptureViolation(name, returns !== undefined, asBinding); + if (missingCapture !== undefined) { + return [yield* raise({ type: "error", message: missingCapture.message, source: name })]; } // Read before the invocation exists: the eval scope ambient here is the diff --git a/packages/core/src/invocation-rules.ts b/packages/core/src/invocation-rules.ts new file mode 100644 index 000000000..3564627ef --- /dev/null +++ b/packages/core/src/invocation-rules.ts @@ -0,0 +1,86 @@ +/** + * What an ordinary component invocation's own syntax decides. + * + * These are the engine's rules about the invocation rather than about the + * component: how `as` may be written, what it may name, and what a component + * that declares `returns` requires of the site that invokes it. None of them + * asks the component anything, and none needs a value the document computes — + * so all of them are decided here, once, and read by every caller. + * + * They are phase-appropriate rather than phase-bound. Whether `as` was written + * as an expression is a fact about authored syntax and is answered from the + * scanned element. What a binding name may be is a fact about a value, and is + * answered from whatever value its caller has: expansion passes the resolved + * prop, validation passes the literal the author wrote. One rule, asked at the + * point each caller can ask it. + * + * Expansion refuses at the first of these and stops, as it always has. + * Validation reports each independent one it finds. Both read this module, so a + * site expansion would refuse is never a site validation calls acceptable. + */ + +import { validateBindingName } from "./live-env.ts"; +import type { StructuralViolation } from "./structural-rules.ts"; +import type { Json } from "./types.ts"; + +/** + * `as` names a binding, so it is rejected on the expression itself rather than + * on a resolved value. + * + * Evaluating it first would make the outcome depend on the host: a bare + * identifier that happens to name a global resolves on one runtime and throws + * `ReferenceError` on another. + */ +export function asExpressionViolation( + name: string, + expressions: Record, +): StructuralViolation | undefined { + return "as" in expressions + ? { + code: "capture-invalid", + source: name, + message: `Prop "as" on <${name} /> must be a string literal.`, + } + : undefined; +} + +/** What a value `as` cannot name a binding with says. */ +export function asBindingViolation( + name: string, + value: Json | undefined, +): StructuralViolation | undefined { + const binding = validateBindingName(value); + return binding.ok + ? undefined + : { + code: "capture-invalid", + source: name, + message: `Prop "as" on <${name} /> ${binding.error.message}`, + }; +} + +/** The binding a well-formed `as` names, or `undefined` when it names none. */ +export function capturedBinding(value: Json | undefined): string | undefined { + const binding = validateBindingName(value); + return binding.ok ? binding.value : undefined; +} + +/** + * A component that declares `returns` renders nothing, so an invocation that + * captures nothing would discard the only thing it produces. + */ +export function returnCaptureViolation( + name: string, + declaresReturns: boolean, + capture: string | undefined, +): StructuralViolation | undefined { + return declaresReturns && capture === undefined + ? { + code: "return-usage-invalid", + source: name, + message: + `<${name} /> declares \`returns\`, so it renders nothing and must be invoked ` + + `with \`as\`: <${name} as="binding" />.`, + } + : undefined; +} diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts index bbb637fe1..53d772dd4 100644 --- a/packages/core/src/structural-rules.ts +++ b/packages/core/src/structural-rules.ts @@ -637,11 +637,6 @@ export function answersDelegateMessage(described: string): string { return ` delegate must be a boolean — ${described}`; } -/** What a literal `delegate` that is not a boolean says. */ -export function answersLiteralDelegateMessage(raw: Json): string { - return answersDelegateMessage(`write delegate={true}, not delegate=${JSON.stringify(raw)}.`); -} - /** What an `` with nothing to answer for says. */ export function answersNoBodyMessage(): string { return ( @@ -651,33 +646,76 @@ export function answersNoBodyMessage(): string { } /** - * Everything `` decides from what the author wrote (spec §6.16.2). + * The props `` accepts, decided from what was written. * - * A `delegate` written as an expression is a value the document computes; - * whether it came back a boolean is expansion's to find out. The body check is - * static: matchers are `` children, and what is left over is the region - * the element answers for. + * `delegate` alone, and it is checked for its *name* here. Whether its value is + * a boolean is decided by `literalDelegate()` below when it is a literal, and by + * expansion when it is an expression the document computes. */ -export function answersViolations(segment: ComponentElement): StructuralViolation[] { +export function answersPropNameViolations(segment: ComponentElement): StructuralViolation[] { const found: StructuralViolation[] = []; for (const name of authoredPropNames(segment)) { if (name !== "delegate") { found.push(violation("structural-usage-invalid", "Answers", answersPropMessage(name))); } } - if (!("delegate" in segment.expressions) && "delegate" in segment.props) { - const raw = segment.props.delegate; - if (typeof raw !== "boolean") { - found.push( - violation("structural-usage-invalid", "Answers", answersLiteralDelegateMessage(raw)), - ); - } + return found; +} + +/** + * Whether this `` delegates, read from the literal the author wrote. + * + * Absent is `false`. A `delegate` written as an expression is a value the + * document computes and is not answered here — expansion evaluates it and holds + * the result to this same contract. + */ +export function literalDelegate(segment: ComponentElement): Result { + if ("delegate" in segment.expressions || !("delegate" in segment.props)) { + return Ok(false); } + const raw = segment.props.delegate; + if (typeof raw !== "boolean") { + return Err( + new Error( + answersDelegateMessage(`write delegate={true}, not delegate=${JSON.stringify(raw)}.`), + ), + ); + } + return Ok(raw); +} + +/** + * Whether this `` has a region to answer for. + * + * Matchers are its `` children; whatever is left is the body. An + * element holding only matchers can never answer anything, which is a fact + * about its source rather than about what its body turned out to render. + */ +export function answersBodyViolation(segment: ComponentElement): StructuralViolation | undefined { const body = segment.children.filter( (child) => !(child.type === "component" && child.name === "Answer"), ); - if (segment.selfClosing || body.every(isBlankText)) { - found.push(violation("structural-usage-invalid", "Answers", answersNoBodyMessage())); + return segment.selfClosing || body.every(isBlankText) + ? violation("structural-usage-invalid", "Answers", answersNoBodyMessage()) + : undefined; +} + +/** + * Everything `` decides from what the author wrote (spec §6.16.2). + * + * The aggregate a caller reporting every independent fact reads. Expansion + * reads the same three decisions one at a time, in the places its own order + * puts them. + */ +export function answersViolations(segment: ComponentElement): StructuralViolation[] { + const found = answersPropNameViolations(segment); + const delegate = literalDelegate(segment); + if (!delegate.ok) { + found.push(violation("structural-usage-invalid", "Answers", delegate.error.message)); + } + const body = answersBodyViolation(segment); + if (body !== undefined) { + found.push(body); } return found; } @@ -706,14 +744,13 @@ export function answerMissingValueMessage(): string { } /** - * Everything one `` matcher decides from what the author wrote. + * The shape of one `` matcher, decided from what was written. * - * The template itself is not parsed here when it is written as children: those - * children render, and rendering is expansion's. A literal `template` prop is a - * string the author wrote, so the rest of the matcher's shape is decided from - * presence alone. + * Which props it carries, and how its template was written — never what the + * template *says*, because template children render, and rendering is + * expansion's. */ -export function answerViolations(segment: ComponentElement): StructuralViolation[] { +export function answerFormViolations(segment: ComponentElement): StructuralViolation[] { const found: StructuralViolation[] = []; for (const name of authoredPropNames(segment)) { if (name !== "template" && name !== "value") { @@ -727,8 +764,28 @@ export function answerViolations(segment: ComponentElement): StructuralViolation if (typeof segment.props.template === "string" && hasChildren) { found.push(violation("structural-usage-invalid", "Answer", answerTemplateBothMessage())); } - if (!("value" in segment.props) && !("value" in segment.expressions)) { - found.push(violation("structural-usage-invalid", "Answer", answerMissingValueMessage())); + return found; +} + +/** An `` that supplies no value answers nothing. */ +export function answerValueViolation(segment: ComponentElement): StructuralViolation | undefined { + return "value" in segment.props || "value" in segment.expressions + ? undefined + : violation("structural-usage-invalid", "Answer", answerMissingValueMessage()); +} + +/** + * Everything one `` matcher decides from what the author wrote. + * + * The aggregate a caller reporting every independent fact reads. Expansion + * reads the form decisions before it renders a template and the value decision + * after, which is the order its own phases put them in. + */ +export function answerViolations(segment: ComponentElement): StructuralViolation[] { + const found = answerFormViolations(segment); + const value = answerValueViolation(segment); + if (value !== undefined) { + found.push(value); } return found; } diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index 137570937..159d784ca 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -19,6 +19,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; import type { Operation } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; import type { DirectoryEntry, LinkStatResult, StatResult } from "@executablemd/runtime"; @@ -28,6 +29,7 @@ import { validateBodyStructure, } from "../src/body-structure.ts"; import { parseMarkdownDefinition } from "../src/definition.ts"; +import { asText, completion, failureMessage } from "./helpers.ts"; import { documentValidationCodeRank, inlineSource, @@ -55,8 +57,8 @@ const MISSING: StatResult = { exists: false, isFile: false, isDirectory: false } /** * What the run was allowed to do, and what it actually did. * - * `reads` is appended in read order, so "once per source identity" and "never - * imported" are both read off the same list. `effects` records any refused + * `reads` is appended in read order, so "once per path" and "never imported" + * are both read off the same list. `effects` records any refused * boundary that fired at all — it stays empty, and a row that finds anything in * it has found an execution. */ @@ -360,7 +362,7 @@ describe("Tier DV: recursive Markdown sources", () => { expect(seen.effects).toEqual([]); }); - it("DV5: sources are root-first, then FIFO, read once, and a cycle terminates", function* () { + it("DV5: views are root-first, then FIFO, each path read once, and a cycle terminates", function* () { const tree = { "components/Alpha.md": "\n\n\n", "components/Beta.md": "\n", @@ -424,11 +426,12 @@ describe("Tier DV: recursive Markdown sources", () => { expect(seen.effects).toEqual([]); }); - it("DV5: a root sharing a source identity with a component is scanned once", function* () { + it("DV5: an untargeted root is the full-definition view of its own path", function* () { const body = "\n"; - // The root's text is retained rather than read, so a second scan of the - // same identity would show up as a second `Foo` record with nothing read. + // The root is untargeted, so its body is the whole file and it *is* this + // path's full-definition view. A second scan of that view would show up as + // a second `Foo` record. const retained = yield* validating(retainedSource("components/Foo.md", body), { tree: { "components/Foo.md": body }, includes: ["components"], @@ -874,6 +877,133 @@ describe("Tier DV: one parser, one rule catalog", () => { }); }); +/** + * What expansion prints for one document, so a shared rule can be measured from + * both sides. + * + * The document really runs here — in its own scope, with the same stubbed tree + * — because the point is that the *other* consumer of the extracted rule says + * the same thing. Under the ordinary printing mode a refused invocation renders + * its error into the output; a root that fails outright reports it instead, so + * both are folded into one string. + */ +function expansionPrints(source: string, scenario: Scenario = {}): Operation { + return scoped(function* () { + const seen = probe(); + yield* useEnvironment(scenario.tree ?? {}, seen); + if (scenario.registrations !== undefined) { + yield* registerComponents(scenario.registrations); + } + const result = yield* completion({ + ...inlineSource(source), + stream: new InMemoryStream(), + ...(scenario.includes === undefined ? {} : { includes: [...scenario.includes] }), + }); + return result.ok ? asText(result.value) : failureMessage(result); + }); +} + +/** + * One authored mistake, as both consumers of the rule that decides it report it. + * + * Expansion positions its sentence and validation carries the position as a + * field, so the diagnostic's message is the substring: what is being measured is + * that there is one sentence, from one decision, rather than two that happen to + * agree today. + */ +function* bothCallersAgree( + source: string, + code: DocumentValidationCode, + scenario: Scenario = {}, +): Operation { + const { result } = yield* validateText(source, scenario); + const found = result.diagnostics.find((diagnostic) => diagnostic.code === code); + if (found === undefined) { + throw new Error(`validation reported no ${code} in [${codes(result).join(", ")}]`); + } + const printed = yield* expansionPrints(source, scenario); + expect(printed).toContain(found.message); + return found.message; +} + +describe("Tier DV: one decision, both callers", () => { + it("DV14: an ordinary component's `as` binding name is decided once", function* () { + // A registration reaches expansion through the function-component path... + const registered = yield* bothCallersAgree('\n', "capture-invalid"); + expect(registered).toBe( + 'Prop "as" on must be a valid JavaScript identifier. Got: "1bad"', + ); + + // ...and a Markdown component through the other one. Both ask this module. + const markdown = yield* bothCallersAgree( + '\n', + "capture-invalid", + { + tree: { "components/Widget.md": WIDGET }, + }, + ); + expect(markdown).toContain('Prop "as" on '); + }); + + it("DV14: `as` written as an expression is refused by one rule", function* () { + const message = yield* bothCallersAgree("\n", "capture-invalid"); + expect(message).toBe('Prop "as" on must be a string literal.'); + }); + + it("DV14: a value component invoked without `as` is refused by one rule", function* () { + const message = yield* bothCallersAgree( + '\n', + "return-usage-invalid", + ); + expect(message).toBe( + " declares `returns`, so it renders nothing and must be invoked with `as`: " + + '.', + ); + }); + + it("DV14: props and body shape are decided once", function* () { + const prop = yield* bothCallersAgree( + "body\n", + "structural-usage-invalid", + ); + expect(prop).toBe(' does not accept a "wrong" prop (allowed: delegate).'); + + const delegate = yield* bothCallersAgree( + 'body\n', + "structural-usage-invalid", + ); + expect(delegate).toBe( + ' delegate must be a boolean — write delegate={true}, not delegate="yes".', + ); + + const body = yield* bothCallersAgree( + '\n', + "structural-usage-invalid", + ); + expect(body).toContain(" has no body to answer for."); + }); + + it("DV14: props, template form and required value are decided once", function* () { + const prop = yield* bothCallersAgree( + "\n\nbody\n\n", + "structural-usage-invalid", + ); + expect(prop).toBe(' does not accept a "bogus" prop (allowed: template, value).'); + + const template = yield* bothCallersAgree( + "\n\nbody\n\n", + "structural-usage-invalid", + ); + expect(template).toContain(" template must be a literal string prop"); + + const value = yield* bothCallersAgree( + '\n\nbody\n\n', + "structural-usage-invalid", + ); + expect(value).toBe(' requires a "value" prop.'); + }); +}); + describe("Tier DV: the no-execution boundary", () => { const HOSTILE = [ "---", diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 514044722..42c6dc2f9 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2955,27 +2955,27 @@ document. **Validation follows source, not execution reachability.** The selected root projection is scanned first; every Markdown definition ordinary selection discovers enters one FIFO queue in the order its first invocation was -encountered, and a source identity is read and parsed exactly once. Every +encountered. Every authored invocation in each source is reported in source order, including one written inside ``, `` or ``, and including children written beneath an origin-only TypeScript component. No branch is evaluated and no projection is predicted. A definition that invokes itself, directly or through -others, terminates the walk when the source identity comes back around, and that -cycle is not itself a failure. An invalid invocation still discovers and queues -the definition it selected. - -**One read per path, one view per role.** Bytes and meaning are separate: a path -is read once however many times the walk needs it, while what is *parsed* from -those bytes depends on who asked. An ordinary component selection carries no -target and asks for the whole Markdown definition. A root asks for the -projection its own selector named. An untargeted root's body is the whole file, -so the two questions have one answer and a component resolving to that path -finds it already scanned — which is how a root that invokes itself terminates. -A **targeted** root's body is one section, and it stands in for nothing: a -component selecting the same path gets its own full-definition view, parsed from -the bytes already read, entering the FIFO queue in discovery order like any -other source. Substituting the projection there would hide every failure in the -sections the root never selected. +others, terminates the walk when that view comes back around, and that cycle is +not itself a failure. An invalid invocation still discovers and queues the +definition it selected. + +**A path's bytes are read once; each view of them is parsed and scanned once.** +Reading and interpreting are separate, because one file can owe the walk more +than one answer. An ordinary component selection carries no target and asks for +the **full definition**. A root asks for the **projection** its own selector +named. An untargeted root's body *is* the whole file, so it is that path's +full-definition view as well: a component resolving there finds it already +scanned, which is how a root that invokes itself terminates. A targeted root's +body is one section and stands in for nothing — the projection and the full +definition are two distinct views of one path, each parsed and scanned once, +from bytes read once, and the definition enters the FIFO queue in discovery +order like any other source. Substituting the projection for it would answer for +sections the root never selected, hiding every failure in them. **A static answer never stands in for a runtime value.** Resolution, authored form, engine-owned body shape and every other structural rule run whenever their @@ -3000,7 +3000,15 @@ spread attribute keeps its execution meaning: validation turns neither into a stricter parse error nor into an unknown-value marker. Selection, declaration admission, schemas, forms, body rules, targets and source positions come from their execution definitions; where a rule had to be extracted from expansion, -both callers read the extracted rule rather than a second catalog. +both callers read the extracted rule rather than a second catalog. That covers +the authored decisions as well as the body contract: how `as` may be written and +what it may name, what a component declaring `returns` requires of its +invocation site, and every `` and `` prop, delegate, body shape +and template rule. Extraction is phase-appropriate rather than phase-bound — +whether `as` was written as an expression is answered from the scanned element, +while what a binding name may be is answered from whatever value each caller +has. Expansion refuses at the first of them, in the phase it always did and with +byte-identical prose; validation reports each independent one it finds. **The answer is versioned data.**