diff --git a/architecture.md b/architecture.md index 5b6c9d58..fc76800f 100644 --- a/architecture.md +++ b/architecture.md @@ -3429,6 +3429,91 @@ 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. 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 +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 +3521,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 #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 | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d91c8f54..8d046ca9 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 ee59990c..7d57c24d 100644 --- a/packages/core/src/answers.ts +++ b/packages/core/src/answers.ts @@ -88,6 +88,16 @@ 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 { + answerFormViolations, + answersBodyViolation, + answersDelegateMessage, + answersPropNameViolations, + answerValueViolation, + isBlankText as isBlankSegment, + literalDelegate, + strayAnswerMessage, +} from "./structural-rules.ts"; /** * The enclosing expansion's recursion, handed in by the arm that dispatched the @@ -151,23 +161,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. */ @@ -218,18 +228,13 @@ 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( - configError(ANSWERS, `does not accept a "${name}" prop (allowed: delegate).`, 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) { - return [yield* raise(configError(ANSWERS, delegate.error, element))]; + return [yield* raise(violationError(delegate.error, ANSWERS, element))]; } const partitioned = yield* partition(element, expand); @@ -241,17 +246,9 @@ 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, - ), - ), - ]; + const bodyRefusal = answersBodyViolation(element); + if (bodyRefusal !== undefined) { + return [yield* raise(violationError(bodyRefusal.message, ANSWERS, element))]; } const bindings = (yield* env)?.values ?? {}; @@ -311,18 +308,13 @@ 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(`<${ANSWERS}> does not accept a "${name}" prop (allowed: delegate).`, 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) { - return Err(new Error(positioned(`<${ANSWERS}> ${delegate.error}`, element))); + return Err(new Error(positioned(delegate.error, element))); } if (delegate.value) { return Err( @@ -351,7 +343,7 @@ function* readChildAnswers( ), ); } - if (!body.every(isBlankText)) { + if (!body.every(isBlankSegment)) { return Err( new Error( positioned( @@ -519,29 +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 refuse(element, `does not accept a "${name}" prop (allowed: template, value).`); - } - } - - // 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 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.", - ); + // 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 refuse(element, "accepts either a template prop or template children, not both."); - } let template: ParsedTemplate | undefined; const source = @@ -564,8 +545,9 @@ function* readAnswer( template = parsed.value; } - if (!("value" in element.props) && !("value" in element.expressions)) { - return refuse(element, 'requires a "value" prop.'); + const valueRefusal = answerValueViolation(element); + if (valueRefusal !== undefined) { + return refuseAnswer(element, valueRefusal.message); } const value = yield* readValue(element); if (value.error) { @@ -575,6 +557,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,27 +628,19 @@ 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 }; } - if (!("delegate" in element.props)) { - return { value: false }; - } - 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: raw }; + const literal = literalDelegate(element); + return literal.ok ? { value: literal.value } : { value: false, error: literal.error.message }; } diff --git a/packages/core/src/body-structure.ts b/packages/core/src/body-structure.ts new file mode 100644 index 00000000..c0ff0c68 --- /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 00000000..861fd445 --- /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 242b6f1c..b88af713 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 00000000..4ce1de71 --- /dev/null +++ b/packages/core/src/document-validation.ts @@ -0,0 +1,1222 @@ +/** + * 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 { + asBindingViolation, + asExpressionViolation, + capturedBinding, + returnCaptureViolation, +} from "./invocation-rules.ts"; +import type { IdentityComponent } from "./invocation-identity.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 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; + 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. 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. + */ +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[] = []; + /** + * 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[] = []; + /** + * 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; + + 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; + } + // 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, + 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; + } + // 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 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); + if (cached !== undefined) { + return cached; + } + const ordinal = this.#nextOrdinal++; + + 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); + 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, + /** 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(); + 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 (view !== undefined) { + this.#sources.set(view, 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) ?? []), + ...(this.#deferred.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)) { + // 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; + } + + 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, 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, 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, + }, + capture, + ); + 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, + }, + capture, + ); + } + + /** + * 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, + capture: AuthoredCapture, + ): 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), + }), + ); + } + + // 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, missingCapture.code, { + message: missingCapture.message, + component: missingCapture.source, + ...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": { + // 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) { + found.push(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 []; + } + } + + /** 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, + 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].sort(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)!)), + ].sort((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; +} + +/** 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 c6a5314a..a3b81381 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,40 @@ 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 { + asBindingViolation, + asExpressionViolation, + capturedBinding, + returnCaptureViolation, +} from "./invocation-rules.ts"; import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; import { @@ -94,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"; @@ -832,7 +865,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 +886,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 +966,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 +1071,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 +1381,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 +1411,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 +1529,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 +1554,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}".`), - ), - ]; + // 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))]; } - - 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}`))]; - } - const asBinding = asResult.value; + const name = eachItemBinding(segment)!; + const asBinding = eachCaptureBinding(segment); let items: Json | undefined; if ("in" in segment.props) { @@ -1604,11 +1580,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 +1656,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 +1708,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 +1747,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 +1781,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 +1789,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 +1810,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 +1854,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 +1874,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 +1952,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 +1978,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 +2020,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; } @@ -2444,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 @@ -2480,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); @@ -2492,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 @@ -2885,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 @@ -2945,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; @@ -2968,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 @@ -3768,234 +3483,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 +3514,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 386a0191..3a79245b 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 285e74f1..7d4ba4fb 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/invocation-rules.ts b/packages/core/src/invocation-rules.ts new file mode 100644 index 00000000..3564627e --- /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 new file mode 100644 index 00000000..53d772dd --- /dev/null +++ b/packages/core/src/structural-rules.ts @@ -0,0 +1,791 @@ +/** + * 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 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." + ); +} + +/** + * The props `` accepts, decided from what was written. + * + * `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 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))); + } + } + 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"), + ); + 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; +} + +/** 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.'; +} + +/** + * The shape of one `` matcher, decided from what was written. + * + * 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 answerFormViolations(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())); + } + 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 new file mode 100644 index 00000000..159d784c --- /dev/null +++ b/packages/core/tests/document-validation.test.ts @@ -0,0 +1,1116 @@ +/** + * 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 { InMemoryStream } from "@executablemd/durable-streams"; +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 { asText, completion, failureMessage } from "./helpers.ts"; +import { + documentValidationCodeRank, + inlineSource, + registerComponents, + retainedSource, + 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 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. + */ +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: 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", + "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"]); + }); + + 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: an untargeted root is the full-definition view of its own path", function* () { + const body = "\n"; + + // 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"], + }); + + 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", () => { + 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"]); + }); +}); + +/** + * 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 = [ + "---", + "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 b778a8c3..42c6dc2f 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2923,6 +2923,178 @@ 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. 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 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 +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. 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 +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. 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.** + +```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. + +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. + +**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 +9772,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; 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 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; 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 | +| 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 |