Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -3429,13 +3429,99 @@ 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.

| 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>` / `printErrors(fn)` | prints failures | built on main |
| `<Let as="name">` | 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 |
| `<Json value={…} />` | 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 `<Prompt>` or `<File>` keeps its own record of the text it consumed | built on the #452 stack |
Expand Down
13 changes: 13 additions & 0 deletions packages/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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 {
Expand Down
133 changes: 56 additions & 77 deletions packages/core/src/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<Answer>` written outside the `<Answers>` that would have read it. */
export function strayAnswerError(element: ComponentElement): ErrorSegment {
return configError(
ANSWER,
"must be a direct child of <Answers>. It is reserved: it never resolves a " +
"component, and only the <Answers> it belongs to can read it.",
element,
);
return violationError(strayAnswerMessage(), ANSWER, element);
}

/** What one `<Answers>` element holds, before anything is decided about it. */
Expand Down Expand Up @@ -218,18 +228,13 @@ export function* expandAnswers(
/** The region the answered body renders into. */
owner: Segment[],
): Operation<Segment[]> {
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);
Expand All @@ -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 <Answers> 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 ?? {};
Expand Down Expand Up @@ -311,18 +308,13 @@ function* readChildAnswers(
element: ComponentElement,
expand: ExpandSegments,
): Operation<Result<AnswerConfiguration>> {
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(
Expand Down Expand Up @@ -351,7 +343,7 @@ function* readChildAnswers(
),
);
}
if (!body.every(isBlankText)) {
if (!body.every(isBlankSegment)) {
return Err(
new Error(
positioned(
Expand Down Expand Up @@ -519,29 +511,18 @@ function* readAnswer(
expand: ExpandSegments,
index: number,
): Operation<AnswerMatcher | Malformed> {
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. `<WhenPrompt>` 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 =
Expand All @@ -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) {
Expand All @@ -575,6 +557,11 @@ function* readAnswer(
return template ? { template, value: value.parsed } : { value: value.parsed };
}

/** One shared `<Answer>` 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) };
}
Expand Down Expand Up @@ -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 };
}
Loading
Loading