From d701c9b7cc51c1ef587bd066627af4d65660acad Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:53:26 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Address=20a=20root=20document's?= =?UTF-8?q?=20sections=20as=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root document now catalogs its own addressable static headings, resolves one selector to exactly one of them, and projects itself down to the preamble, each ancestor's own content, and that section's subtree before anything expands. Heading discovery parses a masked copy of the body, with the boundary scanner's top-level component spans blanked to spaces of the same length. Remark ends the HTML block it infers for a component at a blank line, so a component child's `#` line surfaces as a root heading without the mask; offsets, lines, and everything outside those spans are untouched, so the mask changes what is seen and never where anything is. Projection retains original source ranges and scans each one under its own origin instead of concatenating and rescanning, so skipped source cannot renumber what follows it and a retained element keeps its expansion ID. The exact resolved target, never the caller's glob, is what the durable root import records. A replay guard resolves the current selector against the recorded content and requires the recorded exact target, in the check phase so a completed journal cannot answer for a section it never ran. `xmd targets`, targeted `xmd run`, and the targeted workflow definition are the later layers of #412 and remain unbuilt. --- architecture.md | 37 + packages/core/mod.ts | 16 +- packages/core/src/definition.ts | 199 +++++- packages/core/src/document-targets.ts | 607 ++++++++++++++++ packages/core/src/execute.ts | 150 +++- packages/core/src/inspect.ts | 31 +- packages/core/src/root-source.ts | 83 ++- packages/core/src/scanner.ts | 31 +- .../tests/document-target-execution.test.ts | 498 ++++++++++++++ packages/core/tests/document-targets.test.ts | 646 ++++++++++++++++++ specs/executable-mdx-spec.md | 227 +++++- 11 files changed, 2473 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/document-targets.ts create mode 100644 packages/core/tests/document-target-execution.test.ts create mode 100644 packages/core/tests/document-targets.test.ts diff --git a/architecture.md b/architecture.md index 8d921091..4d4092b0 100644 --- a/architecture.md +++ b/architecture.md @@ -31,6 +31,7 @@ Existing documents and code get aligned to this section retroactively. | definition base | the Git revision supplied to choose a workflow definition's pinned commit | | Repository base | the optional Git revision from which one named Workspace Repository initializes its primary checkout | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | +| document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | | expansion | one logical evaluation of an authored executable element within a document execution | | expansion ID | a deterministic identifier for one logical expansion; restoring or retrying that expansion preserves the ID, while a distinct evaluation requested by the document receives another | | Git capability | the contextual interface through which workflow infrastructure queries the Git repository associated with the current working directory | @@ -533,6 +534,33 @@ interpreter; graceful Worker shutdown alone is insufficient. Worker Shell exposes no native executable or host PATH and is not described as POSIX or native Bash. +## Document targets + +A root document addresses its own sections. The outline is discovered from the +document's static Markdown alone: only headings in the root flow are targets, +and a heading is addressable only when its text is statically rendered — a +heading generated inside a component, or one carrying an interpolation, has no +stable address, and neither does anything beneath it. Discovery therefore +parses a copy in which the scanner's top-level component spans are blanked, +because a Markdown parser reading raw XMD cannot tell a component's children +from the root flow. + +Selection resolves exactly once, before the document expands and before any +authored effect runs. A selector may glob, but it must name exactly one catalog +entry: naming none and naming several are both failures, and two sections that +canonicalize to the same path stay two entries so the ambiguity is reported +rather than resolved arbitrarily. + +The selector and the target it resolves to are different things, and only one +of them is identity. A selector is invocation input — it describes what a +caller asked for, and two callers may spell the same request differently. The +**exact resolved target** is what ran, so it is what a document execution +records durably, what a targeted workflow definition carries, and what a resumed +run is checked against. A caller's glob is never recorded and never re-resolved +against a newer checkout; a resumed run re-resolves the current selector against +the *recorded* content and refuses to continue unless it still names the +recorded target. + ## Expansion identity Core describes the executable element currently being expanded: @@ -569,6 +597,14 @@ JavaScript object identity. document execution receives expansion identity without installing workflow middleware. +Selecting a document target does not disturb any of this. Projection retains +the original source ranges and scans each one under its own origin, so a +retained element keeps the offset and line it was authored at, and with them +its expansion ID. Two runs of the same document under different targets +therefore agree on the ID of every element they both retain, and may share IDs +without sharing effects — run identity and workflow-definition identity are +what tell those runs apart. + ## Two layers Error handling has two layers: @@ -890,6 +926,7 @@ Status is measured against main. | `` / `printErrors(fn)` | prints failures | built on main | | `` region `output` mode | an undecided error fails the document execution | built on main | | `Expansion` / `getExpansion()` | describes the current logical element expansion | built on main | +| document targets | catalogs a root document's addressable static headings, resolves one selector to one exact target, and projects the document to it before expansion | built on the #412 stack; `xmd targets`, targeted `xmd run`, and the targeted workflow definition are unbuilt | | `useWorkflow()` / `getWorkflowRun()` | associates one document execution with a workflow run | built on main | | `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry | built on the #365 stack; Workspace effect publication is unbuilt | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d1c66473..332f96ac 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -130,8 +130,20 @@ export type { ExecutionApi, DocumentExecution, } from "./src/execute.ts"; -export { INLINE_SOURCE_PATH, inlineSource, rootSourcePath } from "./src/root-source.ts"; -export type { InlineRootDocument, RootDocumentSource } from "./src/root-source.ts"; +export { + fileSource, + formatDocumentReference, + INLINE_SOURCE_PATH, + inlineSource, + rootSourcePath, +} from "./src/root-source.ts"; +export type { + FileRootDocument, + InlineRootDocument, + RootDocumentSource, +} from "./src/root-source.ts"; +export { DocumentTargetError } from "./src/document-targets.ts"; +export type { DocumentTargetErrorKind } from "./src/document-targets.ts"; export { inspectComponent, inspectDocument } from "./src/inspect.ts"; export type { ComponentInfo, diff --git a/packages/core/src/definition.ts b/packages/core/src/definition.ts index f3796d96..f24457b8 100644 --- a/packages/core/src/definition.ts +++ b/packages/core/src/definition.ts @@ -1,8 +1,10 @@ import type { Operation } from "effection"; -import type { ComponentDefinition } from "./types.ts"; +import type { ComponentDefinition, Segment } from "./types.ts"; import { parseFrontmatter } from "./frontmatter.ts"; import { compilePropsSchema, compileReturnsSchema } from "./validate.ts"; -import { scanSegments } from "./scanner.ts"; +import { scanComponentSpans, scanSegments } from "./scanner.ts"; +import { outlineDocument, retainedRanges, selectTarget } from "./document-targets.ts"; +import type { DocumentOutline } from "./document-targets.ts"; import matter from "gray-matter"; @@ -15,56 +17,187 @@ export function isFunctionComponentPath(path: string): boolean { return path.endsWith(".ts"); } +/** A document's frontmatter data, its markdown body, and where the body sits. */ +interface ParsedSource { + data: Record; + content: string; + baseOffset: number; + baseLine: number; +} + /** - * Parse markdown source into a component definition. Execution and - * inspection share this so their frontmatter and schema behavior cannot - * drift: both compile the props and return schemas, so a malformed schema - * fails the same way whether the document runs or is only described. + * Split frontmatter from the markdown body without reading either. + * + * The markdown body is a verbatim suffix of the raw file, so the body start + * is computed by length — never by content search, which could false-match + * body text repeated inside frontmatter. The invariant check turns any + * gray-matter normalization surprise into a loud error instead of silently + * wrong source positions. */ -export function* parseMarkdownDefinition( - name: string, - path: string, - content: string, -): Operation { +function parseSource(path: string, content: string): ParsedSource { const parsed = matter(content); - const { meta, props, returns } = parseFrontmatter(parsed.data); - yield* compilePropsSchema(props); - if (returns !== undefined) { - yield* compileReturnsSchema(returns); - } - // The markdown body is a verbatim suffix of the raw file, so the body start - // is computed by length — never by content search, which could false-match - // body text repeated inside frontmatter. The invariant check turns any - // gray-matter normalization surprise into a loud error instead of silently - // wrong source positions. - const bodyStart = content.length - parsed.content.length; - if (content.slice(bodyStart) !== parsed.content) { + const baseOffset = content.length - parsed.content.length; + if (content.slice(baseOffset) !== parsed.content) { throw new Error(`frontmatter parse did not preserve the markdown body verbatim: ${path}`); } let baseLine = 1; - for (let i = 0; i < bodyStart; i++) { + for (let i = 0; i < baseOffset; i++) { if (content[i] === "\n") { baseLine++; } } - const bodySegments = scanSegments(parsed.content, { - path, - baseOffset: bodyStart, - baseLine, - }); + return { data: parsed.data, content: parsed.content, baseOffset, baseLine }; +} +/** The static heading structure a document's body offers as targets. */ +function documentOutline(path: string, content: string): DocumentOutline { + const body = parseSource(path, content).content; + return outlineDocument(body, scanComponentSpans(body)); +} + +/** + * The exact canonical target a selector names in this document's content. + * + * Synchronous and free of effects, so the resolution that decides *what* runs + * happens before anything runs — including inside the durable operation that + * records the root, and inside a replay guard reading recorded content. + */ +export function resolveDocumentTarget(path: string, content: string, selector: string): string { + return selectTarget(documentOutline(path, content), selector).target; +} + +interface CompiledFrontmatter { + meta: Record; + props: ComponentDefinition["props"]; + returns: ComponentDefinition["returns"]; +} + +function* compileFrontmatter(data: Record): Operation { + const { meta, props, returns } = parseFrontmatter(data); + yield* compilePropsSchema(props); + if (returns !== undefined) { + yield* compileReturnsSchema(returns); + } + return { meta, props, returns }; +} + +function buildDefinition( + name: string, + path: string, + frontmatter: CompiledFrontmatter, + bodySegments: Segment[], +): ComponentDefinition { // `returns` stays absent in text mode: absence is what distinguishes a text // component from one that explicitly declares a string return. const definition: ComponentDefinition = { kind: "markdown", name, path, - meta, - props, + meta: frontmatter.meta, + props: frontmatter.props, bodySegments, }; - if (returns !== undefined) { - definition.returns = returns; + if (frontmatter.returns !== undefined) { + definition.returns = frontmatter.returns; } return definition; } + +/** + * Parse markdown source into a component definition. Execution and + * inspection share this so their frontmatter and schema behavior cannot + * drift: both compile the props and return schemas, so a malformed schema + * fails the same way whether the document runs or is only described. + */ +export function* parseMarkdownDefinition( + name: string, + path: string, + content: string, +): Operation { + const body = parseSource(path, content); + const frontmatter = yield* compileFrontmatter(body.data); + return buildDefinition( + name, + path, + frontmatter, + scanSegments(body.content, { path, baseOffset: body.baseOffset, baseLine: body.baseLine }), + ); +} + +/** A root document as parsed: what it declares, and what it addresses. */ +export interface ParsedRootDocument { + definition: ComponentDefinition; + /** Canonical encoded target fragments in document order, duplicates kept. */ + targets: readonly string[]; + /** The exact canonical target selected, when one was requested. */ + target?: string; +} + +/** + * Parse a root document, projecting it to one target when a selector asks for + * one. + * + * Selection happens here, before any segment exists, so a selector that names + * nothing or names several sections fails with nothing expanded. Without a + * selector the whole body is scanned exactly as an ordinary markdown component + * is. + * + * A projection scans each retained range on its own, with the origin that range + * has in the original file, rather than scanning a concatenated string. Skipped + * source therefore cannot renumber what follows it: a retained element keeps + * the offset and line it was authored at, and with them its expansion ID. + */ +export function* parseRootMarkdownDefinition( + name: string, + path: string, + content: string, + selector?: string, +): Operation { + const body = parseSource(path, content); + const frontmatter = yield* compileFrontmatter(body.data); + const outline = outlineDocument(body.content, scanComponentSpans(body.content)); + + if (selector === undefined) { + const bodySegments = scanSegments(body.content, { + path, + baseOffset: body.baseOffset, + baseLine: body.baseLine, + }); + return { + definition: buildDefinition(name, path, frontmatter, bodySegments), + targets: outline.targets, + }; + } + + const entry = selectTarget(outline, selector); + const newlines = newlineCounts(body.content); + 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]!, + }), + ); + } + return { + definition: buildDefinition(name, path, frontmatter, bodySegments), + targets: outline.targets, + target: entry.target, + }; +} + +/** How many newlines precede each offset, so a retained range knows its line. */ +function newlineCounts(body: string): number[] { + const counts = new Array(body.length + 1); + let seen = 0; + for (let i = 0; i < body.length; i++) { + counts[i] = seen; + if (body[i] === "\n") { + seen++; + } + } + counts[body.length] = seen; + return counts; +} diff --git a/packages/core/src/document-targets.ts b/packages/core/src/document-targets.ts new file mode 100644 index 00000000..8b3912bb --- /dev/null +++ b/packages/core/src/document-targets.ts @@ -0,0 +1,607 @@ +/** + * Document targets (spec §5.4). + * + * A target is an addressable static heading in a root document's Markdown flow. + * Selecting one projects the document down to the preamble, the direct content + * of every ancestor needed to reach it, and its complete subtree — so a section + * of a document runs on its own without its siblings. + * + * Two properties shape everything here. + * + * The outline is discovered from *static* Markdown only. Component children can + * hold text that looks like a heading, and Remark cannot tell the difference: a + * blank line inside component children ends its HTML block and the child heading + * surfaces as a root heading. Discovery therefore parses a masked copy of the + * body, where every top-level component span is replaced by spaces of the same + * length. Offsets, lines, and everything outside those spans are untouched, so + * the mask changes what is *seen*, never where anything *is*. + * + * Projection retains original source ranges rather than a rebuilt document. Each + * retained range is scanned with its own origin, so every retained element keeps + * the offset and line it was authored at — which is what keeps expansion + * identifiers equal between a full run and a targeted one. + */ + +import { remark } from "remark"; +import { toString as mdastToString } from "mdast-util-to-string"; + +import type { ComponentSpan } from "./scanner.ts"; + +/** A half-open slice of the original document body. */ +export interface SourceRange { + readonly start: number; + readonly end: number; +} + +/** One catalog entry: an addressable heading and the path that reaches it. */ +export interface DocumentTarget { + /** The canonical encoded target fragment, without a leading `#`. */ + readonly target: string; + /** The decoded, normalized labels the fragment encodes. */ + readonly labels: readonly string[]; + /** Which heading in the outline this entry addresses. */ + readonly heading: number; +} + +interface OutlineHeading { + readonly depth: number; + readonly start: number; + readonly end: number; + readonly parent: number | undefined; + readonly addressable: boolean; + readonly label: string; +} + +/** The static heading structure of one document body, and what it addresses. */ +export interface DocumentOutline { + readonly headings: readonly OutlineHeading[]; + readonly entries: readonly DocumentTarget[]; + /** Canonical encoded fragments in source order, duplicates retained. */ + readonly targets: readonly string[]; + /** Where the preamble ends: the first outermost heading, or the body end. */ + readonly preambleEnd: number; + readonly bodyLength: number; +} + +/** Why a requested target did not resolve to exactly one catalog entry. */ +export type DocumentTargetErrorKind = "invalid-selector" | "no-match" | "multiple-matches"; + +const KIND_WORDING: ReadonlyMap = new Map([ + ["invalid-selector", "is not a valid document target selector"], + ["no-match", "matches no document target"], + ["multiple-matches", "matches more than one document target"], +]); + +/** + * A requested document target that does not name exactly one section. + * + * An ordinary invocation failure: the caller asked for something the document + * does not offer, and nothing durable or contained is involved. It is raised + * before the document expands, so a run that cannot decide what to execute + * executes nothing. + * + * Everything it carries is rebuilt and frozen here. The selector arrives from a + * command line and the catalog from a parser, and neither object belongs to a + * failure that outlives them. Every reference in the message is canonically + * encoded, so a heading holding a control character cannot reach a diagnostic + * literally. + */ +export class DocumentTargetError extends Error { + readonly kind: DocumentTargetErrorKind; + /** The selector fragment as it was requested, still encoded. */ + readonly selector: string; + /** Canonical encoded targets the selector matched; empty unless ambiguous. */ + readonly matches: readonly string[]; + /** Every canonical encoded target the document offers. */ + readonly available: readonly string[]; + + constructor( + kind: DocumentTargetErrorKind, + selector: string, + matches: readonly string[], + available: readonly string[], + ) { + const listed = kind === "multiple-matches" ? matches : available; + const heading = kind === "multiple-matches" ? "Matched targets:" : "Available targets:"; + super( + `${JSON.stringify(selector)} ${KIND_WORDING.get(kind)}.\n` + + (listed.length === 0 + ? "The document has no targets." + : `${heading}\n${listed.map((target) => ` ${target}`).join("\n")}`), + ); + this.name = "DocumentTargetError"; + this.kind = kind; + this.selector = selector; + this.matches = Object.freeze([...matches]); + this.available = Object.freeze([...available]); + } +} + +const UNRESERVED = /^[A-Za-z0-9\-._~]$/; +const HEX = /^[0-9A-Fa-f]$/; + +const ENCODER = new TextEncoder(); + +function encodeCharacter(character: string): string { + let encoded = ""; + for (const byte of ENCODER.encode(character)) { + encoded += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return encoded; +} + +/** + * Percent-encode one canonical label. Everything outside RFC 3986's unreserved + * set is escaped, so `/`, `*`, `#`, and `%` inside a heading cannot be read as + * hierarchy or operator syntax. + */ +export function encodeTargetLabel(label: string): string { + let encoded = ""; + for (const character of label) { + encoded += UNRESERVED.test(character) ? character : encodeCharacter(character); + } + return encoded; +} + +/** + * Percent-encode a decoded filesystem path. Separators survive as raw `/`; a + * `/` that is part of a filename cannot be told apart from one afterwards, so + * this is a formatter for paths the caller already holds, not a round trip. + */ +export function encodeDocumentPath(path: string): string { + let encoded = ""; + for (const character of path) { + encoded += + character === "/" || UNRESERVED.test(character) ? character : encodeCharacter(character); + } + return encoded; +} + +/** + * Decode one percent-encoded chunk, or `undefined` when it is not decodable. + * + * Malformed escapes, byte sequences that are not UTF-8, and NUL are all + * refused rather than repaired: a selector that cannot be read exactly is not a + * selector this can match against. `+` is an ordinary character — this is URI + * path syntax, not a form encoding. + */ +export function decodePercentEncoded(text: string): string | undefined { + const characters = Array.from(text); + const bytes: number[] = []; + for (let index = 0; index < characters.length; index++) { + const character = characters[index]!; + if (character !== "%") { + for (const byte of ENCODER.encode(character)) { + bytes.push(byte); + } + continue; + } + const high = characters[index + 1]; + const low = characters[index + 2]; + if (high === undefined || low === undefined || !HEX.test(high) || !HEX.test(low)) { + return undefined; + } + bytes.push(Number.parseInt(`${high}${low}`, 16)); + index += 2; + } + try { + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes)); + return decoded.includes("\u0000") ? undefined : decoded; + } catch { + return undefined; + } +} + +/** + * The canonical form of rendered heading text: NFC, every run of Unicode + * whitespace collapsed to one ASCII space, trimmed, case preserved. + */ +export function normalizeLabel(text: string): string { + return text.normalize("NFC").replace(/\s+/gu, " ").trim(); +} + +/** + * Whether a fragment is already an exact canonical target: raw `/` between + * nonempty levels, every level percent-encoded exactly as this module encodes + * it, and no wildcard operator anywhere. + */ +export function isCanonicalTarget(target: string): boolean { + if (target.length === 0) { + return false; + } + return target.split("/").every((level) => { + if (level.length === 0 || level.includes("*")) { + return false; + } + const decoded = decodePercentEncoded(level); + if (decoded === undefined || decoded.length === 0) { + return false; + } + return encodeTargetLabel(decoded) === level; + }); +} + +type LevelPart = + | { readonly kind: "literal"; readonly text: string } + | { readonly kind: "wildcard" }; + +type SelectorLevel = + | { readonly kind: "recursive" } + | { readonly kind: "label"; readonly parts: readonly LevelPart[] }; + +/** + * Parse a target selector into levels, or `undefined` when the syntax is not a + * selector at all. + * + * Raw `/` separates levels and raw `*` is an operator, so the split happens + * before decoding: `%2F` stays a slash inside one label and `%2A` stays a + * literal asterisk. Only the literal chunks between operators are decoded. + */ +function parseSelector(selector: string): readonly SelectorLevel[] | undefined { + if (selector.length === 0 || selector.startsWith("/") || selector.endsWith("/")) { + return undefined; + } + const levels: SelectorLevel[] = []; + for (const raw of selector.split("/")) { + if (raw.length === 0) { + return undefined; + } + if (raw === "**") { + levels.push({ kind: "recursive" }); + continue; + } + const chunks: string[] = []; + for (const chunk of raw.split("*")) { + const decoded = decodePercentEncoded(chunk); + if (decoded === undefined) { + return undefined; + } + chunks.push(decoded.normalize("NFC").replace(/\s+/gu, " ")); + } + // Only the outer edges are trimmed: whitespace beside a wildcard is part of + // what the author asked to match, while the whole level is compared against + // an already-trimmed label. + chunks[0] = chunks[0]!.trimStart(); + chunks[chunks.length - 1] = chunks[chunks.length - 1]!.trimEnd(); + + const parts: LevelPart[] = []; + for (const [index, chunk] of chunks.entries()) { + if (index > 0 && parts[parts.length - 1]?.kind !== "wildcard") { + parts.push({ kind: "wildcard" }); + } + if (chunk.length > 0) { + parts.push({ kind: "literal", text: chunk }); + } + } + levels.push({ kind: "label", parts }); + } + return levels; +} + +/** + * Whether one level's parts match one label, by code point. + * + * A reachability sweep rather than backtracking: each part advances a set of + * positions the label could have been consumed to, so a selector holding many + * wildcards costs the product of its size and the label's, never an exponential + * search. + */ +function matchLabel(parts: readonly LevelPart[], label: readonly string[]): boolean { + let reachable = new Array(label.length + 1).fill(false); + reachable[0] = true; + for (const part of parts) { + const next = new Array(label.length + 1).fill(false); + if (part.kind === "wildcard") { + let open = false; + for (let index = 0; index <= label.length; index++) { + open ||= reachable[index]!; + next[index] = open; + } + } else { + const literal = Array.from(part.text); + for (let index = 0; index + literal.length <= label.length; index++) { + if (!reachable[index]) { + continue; + } + if (literal.every((character, offset) => label[index + offset] === character)) { + next[index + literal.length] = true; + } + } + } + reachable = next; + } + return reachable[label.length]!; +} + +/** Whether a parsed selector matches a canonical label path. */ +function matchPath(levels: readonly SelectorLevel[], path: readonly string[]): boolean { + const characters = path.map((label) => Array.from(label)); + let reachable = new Array(path.length + 1).fill(false); + reachable[0] = true; + for (const level of levels) { + const next = new Array(path.length + 1).fill(false); + if (level.kind === "recursive") { + let open = false; + for (let index = 0; index <= path.length; index++) { + open ||= reachable[index]!; + next[index] = open; + } + } else { + for (let index = 0; index < path.length; index++) { + if (reachable[index] && matchLabel(level.parts, characters[index]!)) { + next[index + 1] = true; + } + } + } + reachable = next; + } + return reachable[path.length]!; +} + +/** + * The one catalog entry a selector names. + * + * Zero matches and several matches are both failures, and both are decided + * here — before the document expands — so an ambiguous request never runs half + * a document to discover it was ambiguous. Duplicate canonical paths stay + * duplicate entries, which is what makes that ambiguity observable at all. + */ +export function selectTarget(outline: DocumentOutline, selector: string): DocumentTarget { + const levels = parseSelector(selector); + if (levels === undefined) { + throw new DocumentTargetError("invalid-selector", selector, [], outline.targets); + } + const matched = outline.entries.filter((entry) => matchPath(levels, entry.labels)); + const first = matched[0]; + if (first === undefined) { + throw new DocumentTargetError("no-match", selector, [], outline.targets); + } + if (matched.length > 1) { + throw new DocumentTargetError( + "multiple-matches", + selector, + matched.map((entry) => entry.target), + outline.targets, + ); + } + return first; +} + +/** + * The interpolation forms a heading's own source may not contain. + * + * A heading whose text is computed is not a stable address, so it is not one. + * `\{` escapes an interpolation back into literal text, which stays static and + * stays addressable. + */ +const INTERPOLATION = + /(\\?)\{(?:(?:meta|props)\.[^}]+|[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\}/g; + +function hasUnescapedInterpolation(source: string): boolean { + for (const match of source.matchAll(INTERPOLATION)) { + if (match[1] !== "\\") { + return true; + } + } + return false; +} + +/** + * A copy of the body with every top-level component span blanked. + * + * Length, newline positions, and every offset are preserved, so a heading found + * in the mask sits at the same place in the original. + */ +function maskComponents(body: string, spans: readonly ComponentSpan[]): string { + if (spans.length === 0) { + return body; + } + let masked = ""; + let cursor = 0; + for (const span of spans) { + masked += body.slice(cursor, span.start); + masked += body.slice(span.start, span.end).replace(/[^\n]/g, " "); + cursor = span.end; + } + return masked + body.slice(cursor); +} + +interface RawHeading { + readonly depth: number; + readonly start: number; + readonly end: number; + readonly text: string; +} + +function rootHeadings(masked: string): RawHeading[] { + const headings: RawHeading[] = []; + for (const child of remark().parse(masked).children) { + if (child.type !== "heading") { + continue; + } + const start = child.position?.start.offset; + const end = child.position?.end.offset; + if (start === undefined || end === undefined) { + continue; + } + headings.push({ + depth: child.depth, + start, + end, + // Read from the masked tree, which is the original text for every heading + // that does not overlap a component span — and one that does is refused + // below, so no label is ever built from blanked source. + text: mdastToString(child, { includeHtml: false, includeImageAlt: true }), + }); + } + return headings; +} + +function overlapsComponent(heading: RawHeading, spans: readonly ComponentSpan[]): boolean { + return spans.some((span) => span.start < heading.end && heading.start < span.end); +} + +/** + * Discover the outline of one document body and the targets it addresses. + * + * The hierarchy is the standard outline stack — a heading's parent is the + * nearest preceding heading with a smaller depth — so skipped depths are + * ordinary. "Outermost" is the smallest depth present, not `h1`. + * + * A single outermost heading is the document's title: it is retained in every + * projection and takes no level in any path, which is why a document that opens + * with one title still addresses its sections by their own names. + */ +export function outlineDocument(body: string, spans: readonly ComponentSpan[]): DocumentOutline { + const raw = rootHeadings(maskComponents(body, spans)); + if (raw.length === 0) { + return { + headings: [], + entries: [], + targets: [], + preambleEnd: body.length, + bodyLength: body.length, + }; + } + + const outermostDepth = Math.min(...raw.map((heading) => heading.depth)); + const outermost = raw.filter((heading) => heading.depth === outermostDepth); + const titleIndex = outermost.length === 1 ? raw.indexOf(outermost[0]!) : undefined; + + const headings: OutlineHeading[] = []; + const stack: number[] = []; + for (const [index, heading] of raw.entries()) { + while (stack.length > 0 && raw[stack[stack.length - 1]!]!.depth >= heading.depth) { + stack.pop(); + } + const label = normalizeLabel(heading.text); + headings.push({ + depth: heading.depth, + start: heading.start, + end: heading.end, + parent: stack[stack.length - 1], + addressable: + label.length > 0 && + !overlapsComponent(heading, spans) && + !hasUnescapedInterpolation(body.slice(heading.start, heading.end)), + label, + }); + stack.push(index); + } + + const entries: DocumentTarget[] = []; + for (let index = 0; index < headings.length; index++) { + const labels = pathLabels(headings, index, titleIndex); + if (labels !== undefined) { + entries.push({ + target: labels.map(encodeTargetLabel).join("/"), + labels, + heading: index, + }); + } + } + + return { + headings, + entries, + targets: entries.map((entry) => entry.target), + preambleEnd: outermost[0]!.start, + bodyLength: body.length, + }; +} + +/** + * The canonical path a heading is addressed by, or `undefined` when it has + * none. + * + * Every level has to be addressable: a heading under one whose text is not + * static cannot be named, so its subtree is unreachable. The sole title is not + * a level, which is what lets a static section under a computed title stay + * addressable. + */ +function pathLabels( + headings: readonly OutlineHeading[], + index: number, + titleIndex: number | undefined, +): readonly string[] | undefined { + if (index === titleIndex) { + return undefined; + } + const labels: string[] = []; + for (let current: number | undefined = index; current !== undefined; ) { + if (current !== titleIndex) { + if (!headings[current]!.addressable) { + return undefined; + } + labels.unshift(headings[current]!.label); + } + current = headings[current]!.parent; + } + return labels.length === 0 ? undefined : labels; +} + +/** Where a heading's own subtree ends: the next heading at its depth or above. */ +function subtreeEnd(outline: DocumentOutline, index: number): number { + const depth = outline.headings[index]!.depth; + for (let next = index + 1; next < outline.headings.length; next++) { + if (outline.headings[next]!.depth <= depth) { + return outline.headings[next]!.start; + } + } + return outline.bodyLength; +} + +/** An ancestor's own content: its heading through its first child heading. */ +function directPrefixEnd(outline: DocumentOutline, index: number): number { + const child = outline.headings[index + 1]; + if (child !== undefined && child.depth > outline.headings[index]!.depth) { + return child.start; + } + return subtreeEnd(outline, index); +} + +/** + * The original source ranges a target retains: the preamble, each ancestor's + * own content, and the selected subtree. + * + * The ranges are returned in source order and never overlap, so scanning them + * in turn reproduces authored positions exactly. Sibling subtrees fall in the + * gaps between them and are never scanned, which is what keeps their + * components, resources, and code blocks from running at all. + */ +export function retainedRanges( + outline: DocumentOutline, + entry: DocumentTarget, +): readonly SourceRange[] { + const ancestors: number[] = []; + for ( + let current = outline.headings[entry.heading]!.parent; + current !== undefined; + current = outline.headings[current]!.parent + ) { + ancestors.unshift(current); + } + + const ranges: SourceRange[] = [{ start: 0, end: outline.preambleEnd }]; + for (const ancestor of ancestors) { + ranges.push({ + start: outline.headings[ancestor]!.start, + end: directPrefixEnd(outline, ancestor), + }); + } + ranges.push({ + start: outline.headings[entry.heading]!.start, + end: subtreeEnd(outline, entry.heading), + }); + + const retained: SourceRange[] = []; + let consumed = 0; + for (const range of ranges) { + const start = Math.max(range.start, consumed); + if (start < range.end) { + retained.push({ start, end: range.end }); + consumed = range.end; + } + } + return retained; +} diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 8426f37f..d15ae2ce 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -17,7 +17,10 @@ import { durableRun, createDurableOperation, ephemeral, + ReplayGuard, + StaleInputError, type DurableStream, + type Yield, } from "@executablemd/durable-streams"; import { exec, readTextFile, cwd } from "@executablemd/runtime"; import { cwd as processCwd } from "@effectionx/fs"; @@ -35,7 +38,7 @@ import type { ReturnsSchema, Segment, } from "./types.ts"; -import { parseJson, parseJsonObject } from "./json.ts"; +import { isJsonObject, parseJson, parseJsonObject } from "./json.ts"; import { compilePropsSchema, compileReturnsSchema, @@ -43,7 +46,12 @@ import { validateProps, } from "./validate.ts"; import { useParseCompiler } from "./components/parse-schema.ts"; -import { isFunctionComponentPath, parseMarkdownDefinition } from "./definition.ts"; +import { + isFunctionComponentPath, + parseMarkdownDefinition, + parseRootMarkdownDefinition, + resolveDocumentTarget, +} from "./definition.ts"; import { parseReturnsDeclaration } from "./frontmatter.ts"; import { expandSegments, @@ -129,7 +137,7 @@ export type ExecuteOptions = RootDocumentSource & ExecuteSettings; * implementation up again in the scope that is running now. */ type DurableSelection = - | { kind: "repository"; path: string; content: string } + | { kind: "repository"; path: string; content: string; target?: string } | { kind: "registered"; origin: string; reserved: boolean }; function* durableImportComponent( @@ -145,10 +153,21 @@ function* durableImportComponent( // Inside the durable operation, so the journal holds the root's identity // and its text: a replay restores both without reading anything, whether // the source was a file or supplied. + // + // The selector resolves here too, against the text this operation is + // about to record, so the exact target the run executed is part of the + // record rather than something a later read has to rediscover. Only the + // exact target is recorded — a glob describes what the caller asked + // for, not what ran. + const path = rootSourcePath(root); + const content = yield* readRootSource(root); + const target = + root.target === undefined ? undefined : resolveDocumentTarget(path, content, root.target); return { kind: "repository", - path: rootSourcePath(root), - content: yield* readRootSource(root), + path, + content, + ...(target === undefined ? {} : { target }), }; } @@ -193,7 +212,7 @@ function* durableImportComponent( return found.definition; } - const { path, content } = selection; + const { path, content, target } = selection; // Function component: .ts file — import() the module if (isFunctionComponentPath(path)) { @@ -238,7 +257,13 @@ function* durableImportComponent( return definition; } - // Markdown component: parse at runtime — deterministic from content + // Markdown component: parse at runtime — deterministic from content. + // A recorded target projects the recorded content, so a resumed run executes + // the same section from the same text the first run recorded, whatever the + // file on disk says now. + if (target !== undefined) { + return (yield* ephemeral(parseRootMarkdownDefinition(name, path, content, target))).definition; + } return yield* ephemeral(parseMarkdownDefinition(name, path, content)); } @@ -246,6 +271,113 @@ function isFunctionComponent(value: unknown): value is FunctionComponent { return typeof value === "function"; } +/** The recorded root import this event is, when it is one that can be read. */ +function recordedRootImport(event: Yield): { content: string; target?: string } | undefined { + if ( + event.description.type !== "import_component" || + event.description.name !== "__root__" || + event.result.status !== "ok" + ) { + return undefined; + } + const record = event.result.value; + if (!isJsonObject(record)) { + return undefined; + } + const content = record["content"]; + const target = record["target"]; + if (typeof content !== "string" || (target !== undefined && typeof target !== "string")) { + return undefined; + } + return target === undefined ? { content } : { content, target }; +} + +/** + * Refuse to replay a run that was recorded against a different section. + * + * Only `type` and `name` decide whether a journal entry matches, and the root + * import's name is the same for every target — so without this, resuming with a + * different selector would restore the recorded content and then project a + * section the recorded run never executed. + * + * The current selector is resolved against the *recorded* content, so a glob + * that still names the same section replays and a glob that now names another + * one does not. A selector that has become invalid or ambiguous against that + * content is refused for the same reason: nothing here may guess which section + * a resumed run meant. + * + * This validates in the check phase rather than the decide phase because + * `durableRun` reuses a recorded root Close before any effect is replayed. A + * decision made later would never run for a completed journal, which is exactly + * the run whose recorded target must still be the one being asked for. + * + * A `StaleInputError`, so it propagates as a durability failure rather than + * being printed into the document. + */ +function refuseChangedRootTarget(root: RootDocumentSource): Operation { + return ReplayGuard.around({ + *check([event], next) { + const recorded = recordedRootImport(event); + if (recorded === undefined) { + return yield* next(event); + } + const requested = resolveRecordedTarget(root, recorded.content); + const compatible = + requested.kind === "whole" + ? recorded.target === undefined + : requested.kind === "exact" && requested.target === recorded.target; + if (!compatible) { + const stale = new StaleInputError( + `the recorded root document import ran ${describeRecorded(recorded.target)}, and this ` + + `run asks for ${describeRequested(requested)}. Re-run the document from the ` + + "start rather than resuming from a journal that recorded another section.", + { coroutineId: event.coroutineId, description: event.description }, + ); + if (requested.kind === "unresolved") { + stale.cause = requested.failure; + } + throw stale; + } + return yield* next(event); + }, + }); +} + +/** What this run's selector names in the recorded content. */ +type RequestedTarget = + | { kind: "whole" } + | { kind: "exact"; target: string } + | { kind: "unresolved"; failure: unknown }; + +function resolveRecordedTarget(root: RootDocumentSource, content: string): RequestedTarget { + if (root.target === undefined) { + return { kind: "whole" }; + } + try { + return { + kind: "exact", + target: resolveDocumentTarget(rootSourcePath(root), content, root.target), + }; + } catch (failure) { + return { kind: "unresolved", failure }; + } +} + +function describeRecorded(target: string | undefined): string { + return target === undefined ? "the whole document" : `the target ${JSON.stringify(target)}`; +} + +function describeRequested(requested: RequestedTarget): string { + switch (requested.kind) { + case "whole": + return "the whole document"; + case "exact": + return `the target ${JSON.stringify(requested.target)}`; + case "unresolved": + return "a target that recorded content no longer names exactly once"; + } +} + const execFactory: ModifierFactory = (_params) => (_args, _next) => (function* () { const context = yield* useCodeBlock(); @@ -864,6 +996,10 @@ function* executeDocument(options: ExecuteOptions): Operation { at: "min" }, ); + // Installed before the durable run, so the check phase sees the recorded + // root import before `durableRun` can reuse a recorded Close. + yield* refuseChangedRootTarget(root); + // The policy is selected here — before the durable run and before any // document, frontmatter, prop, component, or eval code exists — so the // root component import is already behind the gate. What comes back is diff --git a/packages/core/src/inspect.ts b/packages/core/src/inspect.ts index bf3617f9..941669c4 100644 --- a/packages/core/src/inspect.ts +++ b/packages/core/src/inspect.ts @@ -2,7 +2,11 @@ import type { Operation } from "effection"; import { readTextFile } from "@executablemd/runtime"; import type { ComponentOrigin, PropsSchema, ReturnsSchema } from "./types.ts"; -import { isFunctionComponentPath, parseMarkdownDefinition } from "./definition.ts"; +import { + isFunctionComponentPath, + parseMarkdownDefinition, + parseRootMarkdownDefinition, +} from "./definition.ts"; import { Component } from "./component-api.ts"; import { selectComponent } from "./components/select.ts"; import { readRootSource, rootSourcePath } from "./root-source.ts"; @@ -38,6 +42,22 @@ export interface DocumentInfo { * the default, so the mode — not the schema — tells the two apart. */ returnMode: "text" | "value"; + + /** + * Every target the document addresses, as canonical encoded fragments without + * the document path or a leading `#`, in document order. + * + * Duplicates are retained: two sections that canonicalize to the same path + * are an ambiguity a caller can see rather than one a selector resolves + * arbitrarily. + */ + readonly targets: readonly string[]; + + /** + * The exact canonical target the requested selector resolved to. Present only + * when a target was requested and resolved; it is never the caller's glob. + */ + readonly target?: string; } /** @@ -47,6 +67,10 @@ export interface DocumentInfo { * validation as execution, but never expands the document, evaluates a * code block, imports a body component, starts an agent, or creates a * journal — so describing a document is always free of its effects. + * + * Target discovery and selection happen here too. A requested selector that + * names no section, or several, fails as a `DocumentTargetError` — before + * anything is expanded, and without a journal ever existing. */ export function* inspectDocument(options: InspectOptions): Operation { const path = rootSourcePath(options); @@ -58,7 +82,8 @@ export function* inspectDocument(options: InspectOptions): Operation` identity. */ -export function inlineSource(source: string): InlineRootDocument { - return { path: INLINE_SOURCE_PATH, source }; +export function inlineSource( + source: string, + options?: { readonly target?: string }, +): InlineRootDocument { + const target = options?.target; + return { + path: INLINE_SOURCE_PATH, + source, + ...(target === undefined ? {} : { target }), + }; +} + +/** + * A file root document from a URI-style document reference. + * + * The reference is `#`, split at the + * first raw `#`. The path is percent-decoded; the fragment is not, because + * `%2F` has to stay distinguishable from the raw `/` that separates target + * levels — the selector parser splits the hierarchy and operator syntax first + * and decodes only the literal chunks between them. + * + * A reference that cannot be read fails with a `TypeError` carrying nothing but + * fixed wording: the input is a command-line argument, and echoing it back + * would put arbitrary bytes into a diagnostic. + * + * A filename containing `#` is written `%23`, and one containing a literal + * `%HH` sequence is written `%25HH`. + */ +export function fileSource(reference: string): FileRootDocument { + const fragment = reference.indexOf("#"); + const encodedPath = fragment === -1 ? reference : reference.slice(0, fragment); + const path = decodePercentEncoded(encodedPath); + if (path === undefined || path.length === 0) { + throw new TypeError(INVALID_REFERENCE); + } + return fragment === -1 ? { path } : { path, target: reference.slice(fragment + 1) }; +} + +/** + * The canonical reference for a document, and optionally one exact target + * inside it. + * + * The path arrives decoded and is encoded here; the target arrives already + * canonical — `DocumentInfo.target`, or a stored workflow definition's — and is + * validated rather than encoded again, so a canonical `%2F` is never turned + * into `%252F`. Making an authored glob canonical is the selector parser's job, + * not this one's. + * + * This is the one formatter diagnostics, command output, and workflow handoff + * use, so a reference printed by one of them is a reference the others accept. + */ +export function formatDocumentReference(path: string, target?: string): string { + if (path.length === 0) { + throw new TypeError(INVALID_REFERENCE); + } + if (target === undefined) { + return encodeDocumentPath(path); + } + if (!isCanonicalTarget(target)) { + throw new TypeError(INVALID_REFERENCE); + } + return `${encodeDocumentPath(path)}#${target}`; } /** The identity printed errors and source positions report for this root. */ diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 71e83bef..6f07ff48 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -112,6 +112,19 @@ function positionAt(index: PositionIndex, offset: number): SourcePosition { }; } +/** + * Where one top-level component invocation begins and ends in the scanned text. + * + * Half-open, relative to the text handed to the scanner. Internal: this is the + * scanner's own boundary decision, recorded so heading discovery can blank the + * regions this scanner owns before a Markdown parser looks at them. It is not a + * public authority over what a component is. + */ +export interface ComponentSpan { + readonly start: number; + readonly end: number; +} + /** * Scan raw markdown text into segments. * @@ -120,8 +133,16 @@ function positionAt(index: PositionIndex, offset: number): SourcePosition { * * When `origin` is provided, component invocations carry `position` values * expressed in the original file's coordinates. + * + * When `spans` is provided, each top-level component invocation records its + * source span into it, in source order. Collecting them changes nothing about + * the segments produced. */ -export function scanSegments(text: string, origin?: SourceOrigin): Segment[] { +export function scanSegments( + text: string, + origin?: SourceOrigin, + spans?: ComponentSpan[], +): Segment[] { const index: PositionIndex = { origin, lineStarts: computeLineStarts(text) }; const segments: Segment[] = []; let pos = 0; @@ -184,6 +205,7 @@ export function scanSegments(text: string, origin?: SourceOrigin): Segment[] { pushText(segments, text.slice(textStart, pos)); } segments.push(component.segment); + spans?.push({ start: pos, end: component.end }); pos = component.end; textStart = pos; continue; @@ -201,6 +223,13 @@ export function scanSegments(text: string, origin?: SourceOrigin): Segment[] { return segments; } +/** The source spans of the top-level component invocations in `text`. */ +export function scanComponentSpans(text: string): ComponentSpan[] { + const spans: ComponentSpan[] = []; + scanSegments(text, undefined, spans); + return spans; +} + interface FenceOpen { fenceChar: string; fenceLen: number; diff --git a/packages/core/tests/document-target-execution.test.ts b/packages/core/tests/document-target-execution.test.ts new file mode 100644 index 00000000..0236bbde --- /dev/null +++ b/packages/core/tests/document-target-execution.test.ts @@ -0,0 +1,498 @@ +/** + * Tier TX — targeted document execution and replay (spec §5.4, §6.11). + * + * A projected document is not a rendering exercise: what it must prove is that + * a skipped sibling *did not run*, that a retained element kept the identity it + * has in a full run, and that a journal recorded against one section cannot be + * resumed as another. + * + * Every "did not run" assertion is made from a component that records its own + * invocation, not from absent text — text can be absent because it rendered + * empty. Every identity assertion reads the expansion ID the engine derived, + * not a position that merely looks unchanged. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { StaleInputError } from "@executablemd/durable-streams"; +import { API, useHostFiles } from "@executablemd/runtime"; + +import { collect } from "../src/collect.ts"; +import { execute } from "../src/execute.ts"; +import { inspectDocument } from "../src/inspect.ts"; +import { getExpansion } from "../src/expansion.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { DocumentTargetError } from "../src/document-targets.ts"; +import { fileSource, formatDocumentReference, inlineSource } from "../src/root-source.ts"; +import type { RootDocumentSource } from "../src/root-source.ts"; +import { asText } from "./helpers.ts"; + +/** What every `` in a run reported, in the order it expanded. */ +interface Probes { + names: string[]; + ids: string[]; +} + +/** + * `` — proof of expansion. + * + * A component that records its own invocation and its expansion ID. Absent + * output would not distinguish "skipped" from "rendered nothing"; an absent + * entry here can only mean the element never expanded. + */ +function* useProbes(seen: Probes): Operation { + yield* registerComponents([ + { + name: "Probe", + origin: "tier-tx", + props: { type: "object", properties: { name: { type: "string" } } }, + *fn(props) { + const name = props["name"]; + seen.names.push(typeof name === "string" ? name : "?"); + seen.ids.push((yield* getExpansion()).id); + return `[${typeof name === "string" ? name : "?"}]`; + }, + }, + ]); +} + +/** A directory the contextual cwd points at, removed when the test ends. */ +function* useWorkspace(files: Record): Operation { + const root = yield* until(mkdtemp(join(tmpdir(), "xmd-targets-"))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + for (const [name, content] of Object.entries(files)) { + yield* writeTextFile(join(root, name), content); + } + yield* API.Env.around( + { + *cwd() { + return root; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + return root; +} + +function rootImports(stream: InMemoryStream) { + return stream + .snapshot() + .flatMap((event) => + event.type === "yield" && + event.description.type === "import_component" && + event.description.name === "__root__" + ? [event] + : [], + ); +} + +function closes(stream: InMemoryStream) { + return stream.snapshot().filter((event) => event.type === "close"); +} + +/** Run a document and report both its text and what expanded. */ +function run(root: RootDocumentSource, stream: InMemoryStream, seen: Probes): Operation { + return scoped(function* () { + yield* useProbes(seen); + return asText(yield* collect(yield* execute({ ...root, stream }))); + }); +} + +/** The failure a run produced, refusing to pass a success off as one. */ +function* failure( + root: RootDocumentSource, + stream: InMemoryStream, + seen: Probes = { names: [], ids: [] }, +): Operation { + try { + yield* run(root, stream, seen); + } catch (error) { + return error; + } + throw new Error("the run completed instead of failing"); +} + +const SECTIONS = [ + 'preamble ', + "", + "# Title", + "", + 'title content ', + "", + "## Alpha", + "", + 'alpha content ', + "", + "### Inner", + "", + 'inner content ', + "", + "## Beta", + "", + 'beta content ', + "", + "```sh exec", + "echo beta-ran", + "```", + "", +].join("\n"); + +describe("Tier TX — targeted execution", () => { + it("TX1: only the preamble, the ancestors, and the subtree expand", function* () { + const seen: Probes = { names: [], ids: [] }; + const text = yield* run( + inlineSource(SECTIONS, { target: "Alpha/Inner" }), + new InMemoryStream(), + seen, + ); + + expect(seen.names).toEqual(["pre", "title", "alpha", "inner"]); + expect(text).toContain("# Title"); + expect(text).toContain("## Alpha"); + expect(text).toContain("### Inner"); + expect(text).not.toContain("## Beta"); + }); + + it("TX2: a skipped sibling's components and code blocks never run", function* () { + const seen: Probes = { names: [], ids: [] }; + yield* scoped(function* () { + yield* API.Process.around({ + *exec([options], _next) { + throw new Error(`a skipped code block ran: ${JSON.stringify(options.command)}`); + }, + }); + yield* run(inlineSource(SECTIONS, { target: "Alpha" }), new InMemoryStream(), seen); + }); + expect(seen.names).toEqual(["pre", "title", "alpha", "inner"]); + }); + + it("TX3: selecting a non-leaf expands every descendant", function* () { + const seen: Probes = { names: [], ids: [] }; + yield* run(inlineSource(SECTIONS, { target: "Alpha" }), new InMemoryStream(), seen); + expect(seen.names).toContain("inner"); + }); + + it("TX4: a retained element keeps the expansion ID it has in a full run", function* () { + const whole: Probes = { names: [], ids: [] }; + const targeted: Probes = { names: [], ids: [] }; + yield* run(inlineSource(SECTIONS), new InMemoryStream(), whole); + yield* run(inlineSource(SECTIONS, { target: "Beta" }), new InMemoryStream(), targeted); + + const idOf = (probes: Probes, name: string) => probes.ids[probes.names.indexOf(name)]; + expect(targeted.names).toEqual(["pre", "title", "beta"]); + expect(idOf(targeted, "beta")).toBe(idOf(whole, "beta")); + expect(idOf(targeted, "pre")).toBe(idOf(whole, "pre")); + }); + + /** + * The identifier is derived from position, not from what ran. Two targets + * that retain the same element therefore agree with each other and with the + * full run — and seeding identity with the target string would break all + * three at once. + */ + it("TX5: two different targets agree on a shared retained element", function* () { + const alpha: Probes = { names: [], ids: [] }; + const beta: Probes = { names: [], ids: [] }; + yield* run(inlineSource(SECTIONS, { target: "Alpha" }), new InMemoryStream(), alpha); + yield* run(inlineSource(SECTIONS, { target: "Beta" }), new InMemoryStream(), beta); + expect(beta.ids[beta.names.indexOf("title")]).toBe(alpha.ids[alpha.names.indexOf("title")]); + }); + + it("TX6: a file root and an inline root behave identically", function* () { + const workspace = yield* useWorkspace({ "doc.md": SECTIONS }); + const fromFile: Probes = { names: [], ids: [] }; + const fromText: Probes = { names: [], ids: [] }; + const fileText = yield* run( + fileSource(formatDocumentReference(join(workspace, "doc.md"), "Beta")), + new InMemoryStream(), + fromFile, + ); + const inlineText = yield* run( + inlineSource(SECTIONS, { target: "Beta" }), + new InMemoryStream(), + fromText, + ); + expect(fromFile.names).toEqual(fromText.names); + expect(fileText).toBe(inlineText); + }); + + it("TX7: root props and frontmatter apply to the projected body", function* () { + const body = [ + "---", + "title: Doc", + "props:", + " who:", + " type: string", + "---", + "", + "# {meta.title}", + "", + "## Greeting", + "", + "hello {props.who}", + "", + "## Skipped", + "", + "skipped {props.who}", + "", + ].join("\n"); + const text = yield* scoped(function* () { + const execution = yield* execute({ + ...inlineSource(body, { target: "Greeting" }), + stream: new InMemoryStream(), + props: { who: "world" }, + }); + return asText(yield* collect(execution)); + }); + expect(text).toContain("# Doc"); + expect(text).toContain("hello world"); + expect(text).not.toContain("skipped"); + }); + + it("TX8: a value root returns from the projected body", function* () { + const body = [ + "---", + "returns:", + " type: object", + " properties:", + " picked:", + " type: string", + "---", + "", + "# Title", + "", + "## Kept", + "", + '', + "", + ].join("\n"); + const value = yield* collect( + yield* execute({ + ...inlineSource(body, { target: "Kept" }), + stream: new InMemoryStream(), + }), + ); + expect(value).toEqual({ picked: "kept" }); + }); + + it("TX9: `` in the projected body selects what is emitted", function* () { + const body = [ + "# Title", + "", + "title text", + "", + "## Kept", + "", + "", + "chosen", + "", + "", + "not chosen", + "", + ].join("\n"); + const text = asText( + yield* collect( + yield* execute({ + ...inlineSource(body, { target: "Kept" }), + stream: new InMemoryStream(), + }), + ), + ); + expect(text.trim()).toBe("chosen"); + }); + + /** + * Structural preflight applies to the projected body, so a violation the + * caller did not select is not a violation of what runs. + */ + it("TX10: an invalid structure in a skipped sibling is irrelevant", function* () { + const body = [ + "# Title", + "", + "## Kept", + "", + 'kept body ', + "", + "## Broken", + "", + '', + "", + ].join("\n"); + const seen: Probes = { names: [], ids: [] }; + const text = yield* run(inlineSource(body, { target: "Kept" }), new InMemoryStream(), seen); + expect(seen.names).toEqual(["kept"]); + expect(text).toContain("kept body"); + expect(text).not.toContain(" requires"); + }); + + it("TX11: an invalid structure in the retained range fails before any effect", function* () { + const body = [ + "# Title", + "", + '', + "", + "## Kept", + "", + '', + "", + ].join("\n"); + const seen: Probes = { names: [], ids: [] }; + const text = yield* run(inlineSource(body, { target: "Kept" }), new InMemoryStream(), seen); + expect(seen.names).toEqual([]); + expect(text).toContain(" requires"); + }); + + /** + * Resolution sits inside the durable root import, so a target failure travels + * out of `execute()` the way every failure crossing that boundary does: by + * name and message, its class left behind with the journal round trip. The + * typed `DocumentTargetError` is what `inspectDocument()` reports, and + * inspection is where a host resolves a selector before running anything. + */ + it("TX12: a target that resolves to nothing runs no authored effect", function* () { + const stream = new InMemoryStream(); + const seen: Probes = { names: [], ids: [] }; + const error = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream, seen); + expect((error as Error).name).toBe("DocumentTargetError"); + expect((error as Error).message).toContain("matches no document target"); + expect(seen.names).toEqual([]); + // The root import is the only effect the journal saw, and it failed. + expect(rootImports(stream).map((event) => event.result.status)).toEqual(["err"]); + expect(stream.snapshot().filter((event) => event.type === "yield").length).toBe(1); + }); + + it("TX13: an ambiguous target runs no authored effect", function* () { + const stream = new InMemoryStream(); + const seen: Probes = { names: [], ids: [] }; + const error = yield* failure(inlineSource(SECTIONS, { target: "**" }), stream, seen); + expect((error as Error).message).toContain("matches more than one document target"); + expect(seen.names).toEqual([]); + expect(stream.snapshot().filter((event) => event.type === "yield").length).toBe(1); + }); + + it("TX14: inspection resolves a target without expanding a component", function* () { + const seen: Probes = { names: [], ids: [] }; + const info = yield* scoped(function* () { + yield* useProbes(seen); + return yield* inspectDocument(inlineSource(SECTIONS, { target: "**/Inner" })); + }); + expect(info.target).toBe("Alpha/Inner"); + expect(seen.names).toEqual([]); + }); +}); + +describe("Tier TX — targeted replay", () => { + it("TX15: the journal records the exact target, never the glob", function* () { + const stream = new InMemoryStream(); + const seen: Probes = { names: [], ids: [] }; + yield* run(inlineSource(SECTIONS, { target: "**/I*" }), stream, seen); + + const imports = rootImports(stream); + expect(imports.length).toBe(1); + expect(imports[0]).toMatchObject({ + result: { status: "ok", value: { target: "Alpha/Inner" } }, + }); + }); + + it("TX16: an untargeted run records no target member at all", function* () { + const stream = new InMemoryStream(); + yield* run(inlineSource(SECTIONS), stream, { names: [], ids: [] }); + const recorded = rootImports(stream)[0]; + expect(recorded?.result.status).toBe("ok"); + const value = recorded?.result.status === "ok" ? recorded.result.value : undefined; + expect(value !== null && typeof value === "object" && "target" in value).toBe(false); + }); + + it("TX17: a different selector naming the same section replays", function* () { + const stream = new InMemoryStream(); + const first: Probes = { names: [], ids: [] }; + const golden = yield* run(inlineSource(SECTIONS, { target: "Alpha/Inner" }), stream, first); + + const second: Probes = { names: [], ids: [] }; + const replayed = yield* run(inlineSource(SECTIONS, { target: "**/I*" }), stream, second); + + expect(replayed).toBe(golden); + expect(rootImports(stream).length).toBe(1); + }); + + /** + * The reuse this has to beat is the root Close, which `durableRun` honours + * before any effect is replayed. Validating in the decide phase alone would + * leave a completed journal answering for a section it never ran. + */ + it("TX18: a different exact target refuses to reuse a completed journal", function* () { + const stream = new InMemoryStream(); + yield* run(inlineSource(SECTIONS, { target: "Alpha" }), stream, { names: [], ids: [] }); + expect(closes(stream).length).toBeGreaterThan(0); + + const error = yield* failure(inlineSource(SECTIONS, { target: "Beta" }), stream); + expect(error).toBeInstanceOf(StaleInputError); + expect((error as Error).message).toContain("Alpha"); + expect((error as Error).message).toContain("Beta"); + }); + + it("TX19: an untargeted request refuses a targeted journal", function* () { + const stream = new InMemoryStream(); + yield* run(inlineSource(SECTIONS, { target: "Beta" }), stream, { names: [], ids: [] }); + + const error = yield* failure(inlineSource(SECTIONS), stream); + expect(error).toBeInstanceOf(StaleInputError); + expect((error as Error).message).toContain("the whole document"); + }); + + it("TX20: a targeted request refuses an untargeted journal", function* () { + const stream = new InMemoryStream(); + yield* run(inlineSource(SECTIONS), stream, { names: [], ids: [] }); + + const error = yield* failure(inlineSource(SECTIONS, { target: "Beta" }), stream); + expect(error).toBeInstanceOf(StaleInputError); + }); + + it("TX21: a selector the recorded content no longer resolves fails stale", function* () { + const stream = new InMemoryStream(); + yield* run(inlineSource(SECTIONS, { target: "Alpha" }), stream, { names: [], ids: [] }); + + // "**" is ambiguous against the recorded content, so this run cannot show + // that it means the recorded section. + const error = yield* failure(inlineSource(SECTIONS, { target: "**" }), stream); + expect(error).toBeInstanceOf(StaleInputError); + expect((error as Error).cause).toBeInstanceOf(DocumentTargetError); + }); + + it("TX22: an untargeted journal still replays for an untargeted run", function* () { + const stream = new InMemoryStream(); + const golden = yield* run(inlineSource(SECTIONS), stream, { names: [], ids: [] }); + const replayed = yield* run(inlineSource(SECTIONS), stream, { names: [], ids: [] }); + expect(replayed).toBe(golden); + expect(rootImports(stream).length).toBe(1); + }); + + /** + * A replay projects the text the journal holds. Rewriting the file between + * runs would change which section a re-resolved selector names if the current + * copy were consulted; it does not, so the replayed output is the first run's. + */ + it("TX23: replay projects the recorded content, not the file on disk", function* () { + const workspace = yield* useWorkspace({ "doc.md": SECTIONS }); + const reference = formatDocumentReference(join(workspace, "doc.md"), "Beta"); + const stream = new InMemoryStream(); + const golden = yield* run(fileSource(reference), stream, { names: [], ids: [] }); + + yield* writeTextFile( + join(workspace, "doc.md"), + ["# Title", "", "## Beta", "", "rewritten beta", ""].join("\n"), + ); + + const replayed = yield* run(fileSource(reference), stream, { names: [], ids: [] }); + expect(replayed).toBe(golden); + expect(replayed).not.toContain("rewritten beta"); + }); +}); diff --git a/packages/core/tests/document-targets.test.ts b/packages/core/tests/document-targets.test.ts new file mode 100644 index 00000000..92cedfc0 --- /dev/null +++ b/packages/core/tests/document-targets.test.ts @@ -0,0 +1,646 @@ +/** + * Tier DT — document targets (spec §5.4). + * + * A target is an addressable static heading, and selecting one runs the + * preamble, each ancestor's own content, and that heading's subtree. These + * assert the three properties the feature stands on. + * + * **Discovery cannot see inside a component.** The masked parse is not a + * refinement of a Remark parse — a component child holding a blank line and a + * `#` line surfaces as a root heading without it, so DT13 fails outright + * against raw Remark discovery. + * + * **A selector resolves exactly once, before anything runs.** Zero matches and + * several matches are both failures, and duplicate canonical paths stay + * duplicates so the ambiguity is visible rather than silently resolved. + * + * **Projection retains original ranges.** The assertions read the projected + * source and the scanned positions rather than a rendering, because the + * position is what an expansion identifier is derived from. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; + +import { + DocumentTargetError, + encodeTargetLabel, + isCanonicalTarget, + normalizeLabel, + outlineDocument, + retainedRanges, + selectTarget, +} from "../src/document-targets.ts"; +import { scanComponentSpans } from "../src/scanner.ts"; +import { parseRootMarkdownDefinition } from "../src/definition.ts"; +import { fileSource, formatDocumentReference, inlineSource } from "../src/root-source.ts"; +import { inspectDocument } from "../src/inspect.ts"; + +function outline(body: string) { + return outlineDocument(body, scanComponentSpans(body)); +} + +function catalog(body: string): readonly string[] { + return outline(body).targets; +} + +function project(body: string, selector: string): string { + const found = outline(body); + const entry = selectTarget(found, selector); + return retainedRanges(found, entry) + .map((range) => body.slice(range.start, range.end)) + .join(""); +} + +/** The failure a selector produced, refusing to pass a success off as one. */ +function refusal(body: string, selector: string): DocumentTargetError { + try { + selectTarget(outline(body), selector); + } catch (error) { + if (error instanceof DocumentTargetError) { + return error; + } + throw error; + } + throw new Error(`${selector} resolved instead of failing`); +} + +const SECTIONS = [ + "preamble", + "", + "# Title", + "", + "intro", + "", + "## Test", + "", + "test intro", + "", + "### Node", + "", + "node body", + "", + "### Bun", + "", + "bun body", + "", + "## Other", + "", + "other body", + "", +].join("\n"); + +describe("Tier DT — document target catalog", () => { + it("DT1: catalogs ATX headings in source order under a sole title", function* () { + expect(catalog(SECTIONS)).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); + }); + + it("DT2: a Setext heading is an ordinary outline heading", function* () { + const body = ["Title", "=====", "", "Section", "-------", "", "body", ""].join("\n"); + expect(catalog(body)).toEqual(["Section"]); + }); + + it("DT3: a skipped depth still nests, and the depth itself is not the path", function* () { + const body = ["# Title", "", "#### Deep", "", "body", ""].join("\n"); + expect(catalog(body)).toEqual(["Deep"]); + }); + + it("DT4: the outermost depth is the smallest present, not h1", function* () { + const body = ["## A", "", "### A1", "", "## B", ""].join("\n"); + expect(catalog(body)).toEqual(["A", "A/A1", "B"]); + }); + + it("DT5: several outermost headings all take a path level", function* () { + const body = ["# A", "", "## A1", "", "# B", "", "## B1", ""].join("\n"); + expect(catalog(body)).toEqual(["A", "A/A1", "B", "B/B1"]); + }); + + it("DT6: matching is case sensitive", function* () { + const body = ["# Title", "", "## Test", ""].join("\n"); + expect(refusal(body, "test").kind).toBe("no-match"); + expect(selectTarget(outline(body), "Test").target).toBe("Test"); + }); + + it("DT7: a label is the statically rendered text, formatting removed", function* () { + const body = [ + "# Title", + "", + "## **Bold** and _italic_", + "", + "## A [link](https://example.test/x) here", + "", + "## Inline `code` text", + "", + "## Alt ![a picture](img.png) text", + "", + "## Tagged text", + "", + ].join("\n"); + expect(catalog(body)).toEqual([ + "Bold%20and%20italic", + "A%20link%20here", + "Inline%20code%20text", + "Alt%20a%20picture%20text", + "Tagged%20text", + ]); + }); + + it("DT8: NFC-equivalent spellings are one label, and Unicode space collapses", function* () { + const decomposed = ["# Title", "", "## Café   name", ""].join("\n"); + expect(catalog(decomposed)).toEqual([encodeTargetLabel("Café name")]); + expect(normalizeLabel("Café   name")).toBe("Café name"); + // The precomposed spelling addresses the decomposed heading. + expect(selectTarget(outline(decomposed), encodeTargetLabel("Café name")).labels).toEqual([ + "Café name", + ]); + }); + + it("DT9: a heading that renders no text is not addressable", function* () { + const body = ["# Title", "", "##", "", "body", "", "## Real", ""].join("\n"); + expect(catalog(body)).toEqual(["Real"]); + }); + + it("DT10: reserved characters are percent-encoded, never left as syntax", function* () { + const body = [ + "# Title", + "", + "## a/b", + "", + "## 100% done", + "", + "## C\\# sharp", + "", + "## star \\* here", + "", + ].join("\n"); + expect(catalog(body)).toEqual(["a%2Fb", "100%25%20done", "C%23%20sharp", "star%20%2A%20here"]); + // `%2F` addresses one label containing a slash; a raw `/` would be hierarchy. + expect(selectTarget(outline(body), "a%2Fb").labels).toEqual(["a/b"]); + expect(refusal(body, "a/b").kind).toBe("no-match"); + // `%2A` is a literal asterisk; a raw `*` is the operator. + expect(selectTarget(outline(body), "star%20%2A%20here").labels).toEqual(["star * here"]); + }); + + it("DT11: duplicate canonical paths stay duplicate entries", function* () { + const body = ["# Title", "", "## Same", "", "one", "", "## Same", "", "two", ""].join("\n"); + expect(catalog(body)).toEqual(["Same", "Same"]); + const ambiguous = refusal(body, "Same"); + expect(ambiguous.kind).toBe("multiple-matches"); + expect(ambiguous.matches).toEqual(["Same", "Same"]); + }); + + it("DT12: only root-flow headings count", function* () { + const body = [ + "# Title", + "", + "> # Quoted", + "", + "- # Listed", + "", + "```md", + "# Fenced", + "```", + "", + "```sh exec", + "# Executed", + "```", + "", + "
", + "# Raw html child", + "
", + "", + "## Real", + "", + ].join("\n"); + expect(catalog(body)).toEqual(["Real"]); + }); + + /** + * The regression that decides the parser boundary. + * + * Remark ends an HTML block at a blank line, so a component child holding one + * puts every following `#` line at the root of the tree. Discovery therefore + * parses a masked copy in which the component's whole span is blanked. Remove + * the mask and `Inner` appears here. + */ + it("DT13: a component child's apparent headings are never targets", function* () { + const body = [ + "# Title", + "", + "", + "", + "# Inner", + "", + "some text", + "", + "## Inner two", + "", + "", + "", + "## Real", + "", + "real body", + "", + ].join("\n"); + expect(catalog(body)).toEqual(["Real"]); + }); + + it("DT14: a heading overlapping component syntax is not addressable", function* () { + const body = ["# Title", "", "## Head tail", "", "## Real", ""].join("\n"); + expect(catalog(body)).toEqual(["Real"]); + }); + + it("DT15: an interpolated heading is not addressable, and blocks its subtree", function* () { + const body = [ + "# Title", + "", + "## {meta.name}", + "", + "### Under computed", + "", + "## {binding}", + "", + "## {props.a.b}", + "", + "## Real", + "", + ].join("\n"); + expect(catalog(body)).toEqual(["Real"]); + }); + + it("DT16: escaped interpolation is static text and stays addressable", function* () { + const body = ["# Title", "", "## \\{meta.name\\}", "", "body", ""].join("\n"); + expect(catalog(body)).toEqual(["%7Bmeta.name%7D"]); + expect(selectTarget(outline(body), "%7Bmeta.name%7D").labels).toEqual(["{meta.name}"]); + }); + + /** + * The title is not a path level, so it is not a level that has to be + * addressable either — which is the whole reason the exception exists. + */ + it("DT17: a computed sole title still leaves its sections addressable", function* () { + const body = ["# {meta.title}", "", "## Real", "", "### Deeper", ""].join("\n"); + expect(catalog(body)).toEqual(["Real", "Real/Deeper"]); + }); + + it("DT18: a document with no heading has an empty catalog", function* () { + expect(catalog("just prose\n")).toEqual([]); + expect(refusal("just prose\n", "Anything").available).toEqual([]); + }); + + it("DT19: a sole title is itself no target", function* () { + expect(catalog("# Only\n\nbody\n")).toEqual([]); + }); +}); + +describe("Tier DT — target selectors", () => { + it("DT20: a literal selector matches one whole label", function* () { + expect(selectTarget(outline(SECTIONS), "Test/Node").labels).toEqual(["Test", "Node"]); + expect(refusal(SECTIONS, "Nod").kind).toBe("no-match"); + }); + + it("DT21: `*` matches within one level, in any position, more than once", function* () { + expect(selectTarget(outline(SECTIONS), "Test/N*").target).toBe("Test/Node"); + expect(selectTarget(outline(SECTIONS), "Test/*ode").target).toBe("Test/Node"); + expect(selectTarget(outline(SECTIONS), "Test/N*d*").target).toBe("Test/Node"); + expect(selectTarget(outline(SECTIONS), "*ther").target).toBe("Other"); + // One `*` never crosses a level boundary. + expect(refusal(SECTIONS, "*Node").kind).toBe("no-match"); + }); + + it("DT22: `**` matches zero or more complete levels", function* () { + expect(selectTarget(outline(SECTIONS), "**/Node").target).toBe("Test/Node"); + expect(selectTarget(outline(SECTIONS), "**/Other").target).toBe("Other"); + expect(selectTarget(outline(SECTIONS), "Other/**").target).toBe("Other"); + expect(selectTarget(outline(SECTIONS), "**/Bun/**").target).toBe("Test/Bun"); + }); + + it("DT23: a selector must name exactly one entry", function* () { + expect(refusal(SECTIONS, "**").kind).toBe("multiple-matches"); + expect(refusal(SECTIONS, "**").matches).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); + expect(refusal(SECTIONS, "Missing").kind).toBe("no-match"); + expect(refusal(SECTIONS, "Missing").matches).toEqual([]); + expect(refusal(SECTIONS, "Missing").available).toEqual([ + "Test", + "Test/Node", + "Test/Bun", + "Other", + ]); + }); + + it("DT24: malformed selector syntax is refused as syntax", function* () { + for (const selector of ["", "/Test", "Test/", "Test//Node", "%zz", "Test/%2"]) { + expect(refusal(SECTIONS, selector).kind).toBe("invalid-selector"); + } + }); + + it("DT25: percent decoding is URI path decoding — `+` is a plus", function* () { + const body = ["# Title", "", "## a+b", "", "## a b", ""].join("\n"); + expect(catalog(body)).toEqual(["a%2Bb", "a%20b"]); + expect(selectTarget(outline(body), "a+b").labels).toEqual(["a+b"]); + expect(selectTarget(outline(body), "a%2Bb").labels).toEqual(["a+b"]); + expect(selectTarget(outline(body), "a%20b").labels).toEqual(["a b"]); + }); + + it("DT26: a malformed or NUL-bearing escape never decodes", function* () { + expect(refusal(SECTIONS, "%00").kind).toBe("invalid-selector"); + // A lone continuation byte is not UTF-8. + expect(refusal(SECTIONS, "%80").kind).toBe("invalid-selector"); + }); + + /** + * A backtracking matcher answers this in exponential time; the reachability + * sweep answers it in the product of the two lengths. A regression to + * backtracking does not fail this assertion — it never reaches it. + */ + it("DT27: a wildcard-dense selector against a long label terminates", function* () { + const label = "a".repeat(120); + const body = ["# Title", "", `## ${label}`, ""].join("\n"); + const selector = `${"*a".repeat(30)}*b`; + expect(refusal(body, selector).kind).toBe("no-match"); + expect(selectTarget(outline(body), `${"*a".repeat(30)}*`).labels).toEqual([label]); + }); + + it("DT28: whitespace beside a wildcard is matched; only the outer edges trim", function* () { + const spaced = ["# Title", "", "## alpha beta gamma", ""].join("\n"); + const joined = ["# Title", "", "## alphabetagamma", ""].join("\n"); + expect(selectTarget(outline(spaced), "alpha%20*%20gamma").labels).toEqual(["alpha beta gamma"]); + // The spaces around the wildcard are part of what was asked for. + expect(refusal(joined, "alpha%20*%20gamma").kind).toBe("no-match"); + // The level's own outer whitespace is not, so a padded selector still lands. + expect(selectTarget(outline(spaced), "%20alpha*gamma%20").labels).toEqual(["alpha beta gamma"]); + }); +}); + +describe("Tier DT — canonical references", () => { + it("DT29: a reference splits at the first raw `#`", function* () { + expect(fileSource("README.md")).toEqual({ path: "README.md" }); + expect(fileSource("README.md#Test/Node")).toEqual({ + path: "README.md", + target: "Test/Node", + }); + // A `#` inside the filename is written `%23`; the fragment keeps its own. + expect(fileSource("odd%23name.md#A%23B")).toEqual({ + path: "odd#name.md", + target: "A%23B", + }); + }); + + it("DT30: a path keeps its separators and decodes its escapes", function* () { + expect(fileSource("docs/sub%20dir/a.md").path).toBe("docs/sub dir/a.md"); + // A literal `%HH` in a filename is spelled `%25HH`. + expect(fileSource("lit%2520.md").path).toBe("lit%20.md"); + }); + + it("DT31: an unreadable reference says only that", function* () { + for (const reference of ["", "#Test", "a%zz.md", "a%00b.md"]) { + let caught: unknown; + try { + fileSource(reference); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(TypeError); + expect((caught as Error).message).toBe("Invalid document reference"); + expect(Object.hasOwn(caught as Error, "cause")).toBe(false); + } + }); + + it("DT32: formatting encodes the path and validates an exact target", function* () { + expect(formatDocumentReference("README.md")).toBe("README.md"); + expect(formatDocumentReference("docs/a b.md")).toBe("docs/a%20b.md"); + expect(formatDocumentReference("odd#name.md", "A%23B")).toBe("odd%23name.md#A%23B"); + // Already canonical: encoded once, never twice. + expect(formatDocumentReference("a.md", "a%2Fb")).toBe("a.md#a%2Fb"); + }); + + it("DT33: formatting refuses anything that is not an exact canonical target", function* () { + for (const target of ["", "Test/", "/Test", "Test/*", "**", "a/b*c", "a b", "a%2fb"]) { + let caught: unknown; + try { + formatDocumentReference("a.md", target); + } catch (error) { + caught = error; + } + expect((caught as Error | undefined)?.message).toBe("Invalid document reference"); + } + expect(isCanonicalTarget("Test/Node")).toBe(true); + expect(isCanonicalTarget("Test/%2A")).toBe(true); + expect(isCanonicalTarget("Test/*")).toBe(false); + }); +}); + +describe("Tier DT — projection", () => { + it("DT34: preamble, ancestor content, and the whole selected subtree", function* () { + expect(project(SECTIONS, "Test/Node")).toBe( + [ + "preamble", + "", + "# Title", + "", + "intro", + "", + "## Test", + "", + "test intro", + "", + "### Node", + "", + "node body", + "", + "", + ].join("\n"), + ); + }); + + it("DT35: selecting a non-leaf keeps every descendant", function* () { + const projected = project(SECTIONS, "Test"); + expect(projected).toContain("### Node"); + expect(projected).toContain("### Bun"); + expect(projected).not.toContain("## Other"); + expect(projected).not.toContain("other body"); + }); + + it("DT36: sibling subtrees are absent, earlier and later alike", function* () { + const projected = project(SECTIONS, "Test/Bun"); + expect(projected).toContain("bun body"); + expect(projected).not.toContain("node body"); + expect(projected).not.toContain("### Node"); + expect(projected).not.toContain("other body"); + }); + + it("DT37: retained headings and the sole title stay in the projection", function* () { + const projected = project(SECTIONS, "Test/Node"); + expect(projected).toContain("# Title"); + expect(projected).toContain("intro"); + expect(projected).toContain("## Test"); + expect(projected).toContain("test intro"); + }); + + it("DT38: with several outermost headings none is retained by default", function* () { + const body = ["pre", "", "# A", "", "a body", "", "# B", "", "b body", ""].join("\n"); + expect(project(body, "B")).toBe(["pre", "", "# B", "", "b body", ""].join("\n")); + }); + + it("DT39: an ancestor keeps only its own content, not an earlier sibling's", function* () { + const body = [ + "# Title", + "", + "title content", + "", + "## First", + "", + "first content", + "", + "## Second", + "", + "second content", + "", + ].join("\n"); + expect(project(body, "Second")).toBe( + ["# Title", "", "title content", "", "## Second", "", "second content", ""].join("\n"), + ); + }); +}); + +describe("Tier DT — projected parsing", () => { + function* parsed(body: string, selector?: string) { + return yield* parseRootMarkdownDefinition("__root__", "doc.md", body, selector); + } + + it("DT40: a retained element keeps the offset and line it was authored at", function* () { + const body = [ + "# Title", + "", + "## Skipped", + "", + "x".repeat(400), + "", + "## Kept", + "", + "", + "", + ].join("\n"); + + const whole = yield* parsed(body); + const targeted = yield* parsed(body, "Kept"); + const positionOf = (definition: { bodySegments: readonly unknown[] }) => + definition.bodySegments + .flatMap((segment) => + typeof segment === "object" && + segment !== null && + "type" in segment && + segment.type === "component" + ? [segment] + : [], + ) + .map((segment) => (segment as { position?: unknown }).position); + + expect(positionOf(whole.definition)).toEqual(positionOf(targeted.definition)); + expect(targeted.target).toBe("Kept"); + }); + + it("DT41: CRLF source keeps its original offsets and lines too", function* () { + const body = [ + "# Title", + "", + "## Skipped", + "", + "skipped body", + "", + "## Kept", + "", + "", + "", + ].join("\r\n"); + const whole = yield* parsed(body); + const targeted = yield* parsed(body, "Kept"); + const componentsOf = (segments: readonly unknown[]) => + segments.flatMap((segment) => + typeof segment === "object" && + segment !== null && + "type" in segment && + segment.type === "component" + ? [segment as { position?: { offset: number; line: number } }] + : [], + ); + expect(componentsOf(targeted.definition.bodySegments)[0]?.position).toEqual( + componentsOf(whole.definition.bodySegments)[0]?.position, + ); + }); + + it("DT42: frontmatter, props, and the return mode survive projection", function* () { + const body = [ + "---", + "title: Doc", + "props:", + " name:", + " type: string", + "returns:", + " type: object", + "---", + "", + "# Title", + "", + "## Kept", + "", + "kept", + "", + ].join("\n"); + const targeted = yield* parsed(body, "Kept"); + expect(targeted.definition.meta).toEqual({ title: "Doc" }); + expect(targeted.definition.props).toMatchObject({ properties: { name: { type: "string" } } }); + expect(targeted.definition.returns).toMatchObject({ type: "object" }); + expect(targeted.targets).toEqual(["Kept"]); + }); + + it("DT43: the untargeted parse still scans the whole body", function* () { + const whole = yield* parsed(SECTIONS); + expect(whole.target).toBe(undefined); + expect(whole.targets).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); + const text = whole.definition.bodySegments + .map((segment) => (segment.type === "text" ? segment.content : "")) + .join(""); + expect(text).toBe(SECTIONS); + }); +}); + +describe("Tier DT — inspection", () => { + it("DT44: inspection reports the catalog without selecting anything", function* (): Operation { + const info = yield* inspectDocument(inlineSource(SECTIONS)); + expect(info.targets).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); + expect(info.target).toBe(undefined); + }); + + it("DT45: inspection resolves a glob to the exact canonical target", function* (): Operation { + const info = yield* inspectDocument(inlineSource(SECTIONS, { target: "**/N*" })); + expect(info.target).toBe("Test/Node"); + expect(info.targets).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); + }); + + it("DT46: an unresolvable target fails inspection", function* (): Operation { + let caught: unknown; + try { + yield* inspectDocument(inlineSource(SECTIONS, { target: "Nope" })); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DocumentTargetError); + expect((caught as DocumentTargetError).kind).toBe("no-match"); + expect((caught as DocumentTargetError).selector).toBe("Nope"); + }); + + it("DT47: the error's data is frozen and rebuilt, not the parser's arrays", function* () { + const error = refusal(SECTIONS, "**"); + expect(Object.isFrozen(error.matches)).toBe(true); + expect(Object.isFrozen(error.available)).toBe(true); + expect(error.matches).not.toBe(outline(SECTIONS).targets); + // Encoded throughout, so a control character in a heading cannot reach a + // diagnostic literally. + expect(error.message).toContain('"**"'); + for (const line of error.message.split("\n").slice(1)) { + expect(line).not.toMatch(/[\u0000-\u001F]/); + } + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5beb9e4b..147f5515 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2351,9 +2351,16 @@ operation, with the read it leads to. What the journal holds is serializable: a repository selection records the chosen path and its content, and a registration records its origin, never its function. +The root's selection carries one more member. A targeted root resolves its +selector here too, against the text this operation is about to record, and +records the **exact** target it resolved to — so the section the run executed is +part of the record rather than something a later read rediscovers (§5.4). An +untargeted root records no `target` member, which is what keeps journals written +before targets existed readable. + ```typescript type DurableSelection = - | { kind: "repository"; path: string; content: string } + | { kind: "repository"; path: string; content: string; target?: string } | { kind: "registered"; origin: string; reserved: boolean }; function* durableImportComponent( @@ -2606,6 +2613,210 @@ schema, an invalid value, a body error, and a failure raised after `` all complete `Err`, and body text emitted before the failure remains only on the output stream. +#### Document targets + +A root document addresses its own sections. A **document target** is an +addressable static heading in the document's root Markdown flow, named by the +canonical path of heading labels that reaches it. Selecting one executes: + +1. the document preamble; +2. the direct content of every ancestor needed to reach the target; and +3. the selected heading's complete subtree. + +Sibling subtrees do not execute. Retained headings stay in the projected body, +so the projection reads as a document rather than as an excerpt. + +##### Which headings are targets + +Only root-level Markdown heading nodes form the outline. A heading inside a +block quote, a list, a fenced block, raw HTML, or component children is not one. + +Heading discovery does not parse raw XMD with a Markdown parser. A component's +children are ordinary text to that parser, and a blank line among them ends the +HTML block it inferred, which surfaces a child heading as a root heading. +Discovery instead parses a copy of the body in which the boundary scanner's +top-level component spans are replaced by spaces of the same length. Newline +positions, offsets, and everything outside those spans are unchanged, so a +heading found in the masked copy sits where it sits in the original, and the +original supplies its text and its source. + +A heading's parent is the nearest preceding heading with a smaller depth. +Skipped depths are ordinary. **Outermost** means the smallest heading depth +present in the root flow, which need not be `h1`. + +When the document has exactly one outermost heading, that heading is the +document **title**: it takes no level in any target path, it is no target +itself, and its heading and direct content are retained in every projection +beneath it. When the document has more than one outermost heading, each of them +takes a path level. A document with no addressable heading has an empty +catalog. + +A heading is **not addressable** when its own source overlaps executable +component syntax, or contains an unescaped Executable MDX interpolation — +`{meta.key}`, `{props.key}`, `{binding}`, and the dotted forms of each. Escaped +interpolation (`\{meta.key\}`) is literal static text and stays addressable. A +heading that renders no text is not addressable. An unaddressable heading +required as a path level makes its whole subtree unaddressable; because the sole +title is not a path level, static sections beneath a computed title remain +addressable. + +##### Labels and canonical encoding + +A label is the statically rendered Markdown text of the heading: formatting and +link destinations are removed, while visible text, inline-code text, and image +alternative text are retained. The result is normalized to NFC, every run of +Unicode whitespace collapses to one ASCII space, leading and trailing +whitespace is trimmed, and case is preserved. There are no generated slugs, +suffixes, case folding, or punctuation removal. + +A canonical target is the sequence of labels from the target's outermost +addressable ancestor to the target, each percent-encoded and joined with raw +`/`. Encoding leaves the RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`) +alone and escapes everything else as uppercase UTF-8 hexadecimal, so a `/`, +`*`, `#`, or `%` inside a heading becomes `%2F`, `%2A`, `%23`, or `%25` and +cannot be read as syntax. + +The catalog is in source order and retains duplicates: two sections whose +canonical paths are equal stay two entries, so the ambiguity is observable. + +##### Selectors + +A document reference is: + +```text +# +``` + +The first raw `#` separates the two. Raw `/` separates target levels and raw +`*` and `**` are operators; the selector is split on those before its literal +chunks are percent-decoded, which is what keeps `%2F` a slash inside one label +and `%2A` a literal asterisk. Decoding is URI path decoding: `+` is a plus, not +a space. Malformed escapes, byte sequences that are not UTF-8, NUL, a leading +or trailing slash, and an empty level are all refused. Matching is +case-sensitive. + +- A literal level matches one canonical label exactly, after decoding and label + normalization. +- `*` within a level matches zero or more characters of that one label, and may + appear more than once. +- A level that is exactly `**` matches zero or more complete path levels. + +There is no `?`, character class, brace, or backslash dialect. Within a +wildcard level only the literal chunks are decoded and normalized; whitespace +beside a wildcard is part of what the selector asked for, and only the beginning +of the first chunk and the end of the last are trimmed. Matching compares +Unicode code points and completes in time bounded by the product of the pattern +and label sizes. + +A selector must resolve to exactly one catalog entry. Zero matches and several +matches both fail. Diagnostics report canonical encoded references, so a +duplicate canonical path is reported as an ambiguity rather than resolved. + +##### Projection + +Source ranges are defined against the original, unprojected body: + +- the **preamble** runs from the body start to immediately before the first + outermost heading; +- an **ancestor's direct content** runs from its heading start to its first + child heading's start, or to its subtree end when it has no child heading; + and +- the **selected subtree** runs from the selected heading's start to the next + heading of equal or smaller depth, or to the body end. + +The projected body is the preamble, each retained ancestor's direct content in +order, and the selected subtree. For a sole outermost title, the title is the +first retained ancestor even though it takes no level in the path. + +Each retained range is scanned separately, under the origin that range has in +the original file — its path, its offset, and its line. The ranges are not +concatenated and rescanned: skipped source must not renumber what follows it, +because a retained element's source position is what its expansion identifier +is derived from. A retained element therefore carries the same expansion ID in +a targeted run as in a full one, and two targets that retain it agree with each +other. The target string takes no part in expansion identity; a run's own +identity is what distinguishes the effects of two target runs. + +Frontmatter, root props, `returns`, the return mode, and `` behavior are +unchanged and apply to the projected body. Structural validation applies to the +projected body too: an invalid skipped sibling is irrelevant, while an invalid +retained range fails before any authored effect in the projection runs. + +##### Failure timing and durable identity + +Selection happens before the body expands. An invalid, unmatched, or ambiguous +selector runs no authored document effect. + +The live root import records the **exact canonical target**, never the caller's +selector. An untargeted import records no target member at all, so journals +written before targets existed stay readable by untargeted runs. + +A replay guard validates the target before the recorded run is reused. It parses +the recorded root content, resolves the current selector against *that* content, +and requires the result to equal the recorded exact target; the recorded content +is then what the projection is taken from. A different selector naming the same +section replays. A different exact target, a targeted request against an +untargeted record, an untargeted request against a targeted record, and a +selector the recorded content no longer resolves are all stale input (§6.11). +The check runs before a completed run's recorded terminal result can be reused, +so a finished journal cannot answer for a section it never ran. + +##### Naming a root document + +`@executablemd/core` exposes the shared shapes: + +```ts +interface FileRootDocument { + readonly path: string; + readonly source?: undefined; + readonly target?: string; +} + +interface InlineRootDocument { + readonly path: ""; + readonly source: string; + readonly target?: string; +} + +type RootDocumentSource = FileRootDocument | InlineRootDocument; + +function fileSource(reference: string): FileRootDocument; +function inlineSource(source: string, options?: { readonly target?: string }): InlineRootDocument; +function formatDocumentReference(path: string, target?: string): string; +``` + +`fileSource()` splits a document reference at the first raw `#`, percent-decodes +the path portion, and stores the fragment — still encoded — as `target`. It does +not decode the fragment as one string, because `%2F` must stay distinguishable +from a level separator. An empty path, a malformed escape, a byte sequence that +is not UTF-8, and NUL each fail with a cause-free `TypeError` whose message is +exactly `Invalid document reference`; the input is a command-line argument, and +echoing it back would put arbitrary bytes into a diagnostic. A filename +containing `#` is written `%23`, and one containing a literal `%HH` sequence is +written `%25HH`. + +`formatDocumentReference()` takes a decoded path and, optionally, an +already-canonical exact target. It encodes the path, validates the target rather +than encoding it again, and joins them with `#`. It is the one formatter +diagnostics, command output, and workflow handoff use. Making an authored glob +canonical is the selector parser's work, not this function's. + +Existing programmatic `{ path }` values and `inlineSource(source)` remain valid +and untargeted. + +An unresolvable target raises `DocumentTargetError`, whose `kind` is +`invalid-selector`, `no-match`, or `multiple-matches`. It carries the requested +`selector` as it arrived, the canonical encoded `matches` (empty except for +`multiple-matches`), and every canonical encoded `available` target. Its data is +rebuilt and frozen at the boundary, and its message quotes the selector as JSON +and lists canonical encoded references, so a heading holding a control character +cannot reach a diagnostic literally. It is an ordinary invocation failure, not a +durability or `API.Files` failure. Because target resolution sits inside the +durable root import, a failure reaching a caller through `execute()` arrives by +name and message like every other failure crossing that boundary; the typed +error is what `inspectDocument()` reports, and inspection is where a host +resolves a selector before running anything. + ### 5.5 The Component Api Expansion's context-dependent operations are exposed through one public @@ -5551,6 +5762,9 @@ workflow and returns a `DocumentExecution` handle. Options: - the root document source — either `path`, the path to the root markdown document, or an inline document built with `inlineSource(text)`, which carries the supplied text together with its `` identity +- `target?` — a document target selector, still encoded, resolved against the + root before its body expands (§5.4). `fileSource(reference)` builds a file + root and its selector from one document reference - `stream` — the durable stream that journals the run - `props?` — JSON values supplied to the root document (default: `{}`) - `componentDirs?` — component search directories (default: @@ -5675,9 +5889,18 @@ what it declares — without executing the document or creating a journal: - `returnMode` — `"text"` or `"value"`. An explicit `returns: { type: string }` produces the same effective schema as the default, so the mode is what tells the two apart. +- `targets` — every document target the root addresses, as canonical encoded + fragments without the document path or a leading `#`, in document order, + duplicates retained (§5.4). +- `target` — the exact canonical target the requested selector resolved to. + Present only when a target was requested and resolved, and never the caller's + glob. An invalid return schema fails inspection exactly as it fails execution: both -load the definition through the same path. +load the definition through the same path. So does an unresolvable target: +inspection discovers and selects targets without expanding the document, +evaluating a code block, importing a body component, or creating a journal, so +a host resolves a selector to one exact target before anything runs. `DocumentExecution` is an `Operation>`: `yield* execution` completes with `Ok(value)` on success and `Err(error)` on document, From 568591ecd820121e9345c9d7176cfff8d19f9fd2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:09:33 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Hold=20a=20resumed=20run=20t?= =?UTF-8?q?o=20the=20selection=20its=20journal=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root import whose selector matched nothing failed the effect, so the journal kept only a serialized message and the replay guard delegated past every `err` result. A completed journal written by `Missing` then answered a later request for a section the document really has, with the old `Missing` error. A failed selection is an observation of the document, so it is recorded as one: its kind, the requested selector, the matches, and the catalog. The guard compares whole selection outcomes rather than target strings, reproduces a recorded failure from that record before the recorded Close can be reused, and reports any difference as stale input carrying no foreign object. `DocumentTargetError` now carries frozen, namespaced-tagged data and is recognized structurally, so inspection, a live run, and a replayed run all raise the same error with the same fields across separately loaded copies. A level is canonical only when decoding, label normalization, and canonical re-encoding reproduce it exactly, which refuses NFD, tabs, uncollapsed and edge whitespace, lowercase escapes, empty levels, raw operators, and a raw `#`. `formatDocumentReference()` only formats what `fileSource()` reads back. The recorded selector is sanitized invocation metadata: architecture.md now states that the exact canonical target is definition identity and a caller glob never substitutes for it. --- architecture.md | 31 +- packages/core/mod.ts | 9 +- packages/core/src/definition.ts | 21 +- packages/core/src/document-targets.ts | 377 ++++++++++++++++-- packages/core/src/execute.ts | 238 +++++++---- packages/core/src/root-source.ts | 13 +- .../tests/document-target-execution.test.ts | 94 ++++- packages/core/tests/document-targets.test.ts | 237 ++++++++++- specs/executable-mdx-spec.md | 153 +++++-- 9 files changed, 985 insertions(+), 188 deletions(-) diff --git a/architecture.md b/architecture.md index 4d4092b0..fa7c86d1 100644 --- a/architecture.md +++ b/architecture.md @@ -24,7 +24,7 @@ Existing documents and code get aligned to this section retroactively. | middleware | applied by the lexical structure, used by runtime execution | | workflow run | a workflow being carried out with its progress and outcome recorded durably; document executions perform its work, while ongoing effects remain scoped to the document execution in which they run | | document execution | one evaluation of a root document initiated through `execute()`, producing one output stream and one completion result while reading and appending a durable journal; its ongoing effects belong to the Effection scope in which the evaluation runs | -| workflow definition | what a workflow run is a run of: a versioned descriptor naming an immutable object — its format and object ID — together with the repository-relative path of the root document inside it. A repository locator is not part of it, and it is distinct from every Repository created inside the run's Workspace | +| workflow definition | what a workflow run is a run of: a versioned descriptor naming an immutable object — its format and object ID — together with the repository-relative path of the root document inside it, and the exact canonical document target when one is selected. A repository locator is not part of it, and it is distinct from every Repository created inside the run's Workspace | | retrieval metadata | replaceable, credential-free information about where a workflow definition can be fetched from now; it takes no part in run identity and is reauthorized by the host before use | | stop reason | why a workflow run or a document execution stopped: a categorical host code, or a reference to an already-filtered journal event | | run ID | an opaque stable public identifier generated by the host or selected by an authorized caller; it associates the run's durable records and effects, remains unchanged for the life of the run, and has no semantics beyond equality and lifecycle addressing | @@ -551,15 +551,26 @@ entry: naming none and naming several are both failures, and two sections that canonicalize to the same path stay two entries so the ambiguity is reported rather than resolved arbitrarily. -The selector and the target it resolves to are different things, and only one -of them is identity. A selector is invocation input — it describes what a -caller asked for, and two callers may spell the same request differently. The -**exact resolved target** is what ran, so it is what a document execution -records durably, what a targeted workflow definition carries, and what a resumed -run is checked against. A caller's glob is never recorded and never re-resolved -against a newer checkout; a resumed run re-resolves the current selector against -the *recorded* content and refuses to continue unless it still names the -recorded target. +The selector and the target it resolves to are different things, and only one of +them is identity. + +**The exact canonical target is definition identity.** It is what ran, so it is +what a document execution records durably, what a targeted workflow definition +carries, and what a resumed run is checked against. + +**A caller's glob is non-authoritative invocation metadata.** It describes what +a caller asked for — two callers may spell one request differently — and it +never substitutes for the exact target: it does not occupy the recorded +exact-target field, it never enters a workflow definition, and it is never +re-resolved against a newer checkout to decide what a resumed run means. A glob +is retained in exactly one place, a failed selection's structural record, and +only so that an ordinary failed execution can be reproduced. + +A resumed run re-resolves the current selector against the *recorded* content +and refuses to continue unless the outcome is the one recorded. A failed +selection is an outcome too, and is recorded and compared as one — otherwise a +journal left by a selector that matched nothing would answer a later request for +a section that does exist. ## Expansion identity diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 332f96ac..45af4b8a 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -142,8 +142,13 @@ export type { InlineRootDocument, RootDocumentSource, } from "./src/root-source.ts"; -export { DocumentTargetError } from "./src/document-targets.ts"; -export type { DocumentTargetErrorKind } from "./src/document-targets.ts"; +export { + asDocumentTargetError, + DocumentTargetError, + isDocumentTargetError, + parseDocumentTargetFailure, +} from "./src/document-targets.ts"; +export type { DocumentTargetErrorKind, DocumentTargetFailure } from "./src/document-targets.ts"; export { inspectComponent, inspectDocument } from "./src/inspect.ts"; export type { ComponentInfo, diff --git a/packages/core/src/definition.ts b/packages/core/src/definition.ts index f24457b8..388d7f49 100644 --- a/packages/core/src/definition.ts +++ b/packages/core/src/definition.ts @@ -1,9 +1,10 @@ -import type { Operation } from "effection"; +import { Ok } from "effection"; +import type { Operation, Result } from "effection"; import type { ComponentDefinition, Segment } from "./types.ts"; import { parseFrontmatter } from "./frontmatter.ts"; import { compilePropsSchema, compileReturnsSchema } from "./validate.ts"; import { scanComponentSpans, scanSegments } from "./scanner.ts"; -import { outlineDocument, retainedRanges, selectTarget } from "./document-targets.ts"; +import { findTarget, outlineDocument, retainedRanges, selectTarget } from "./document-targets.ts"; import type { DocumentOutline } from "./document-targets.ts"; import matter from "gray-matter"; @@ -56,14 +57,22 @@ function documentOutline(path: string, content: string): DocumentOutline { } /** - * The exact canonical target a selector names in this document's content. + * The exact canonical target a selector names in this document's content, or + * the failure describing why it names none. * * Synchronous and free of effects, so the resolution that decides *what* runs * happens before anything runs — including inside the durable operation that - * records the root, and inside a replay guard reading recorded content. + * records the root, and inside a replay guard reading recorded content. The + * outcome comes back rather than being thrown because both of those callers + * record it as data before anyone reports it. */ -export function resolveDocumentTarget(path: string, content: string, selector: string): string { - return selectTarget(documentOutline(path, content), selector).target; +export function resolveDocumentTarget( + path: string, + content: string, + selector: string, +): Result { + const found = findTarget(documentOutline(path, content), selector); + return found.ok ? Ok(found.value.target) : found; } interface CompiledFrontmatter { diff --git a/packages/core/src/document-targets.ts b/packages/core/src/document-targets.ts index 8b3912bb..9329aab3 100644 --- a/packages/core/src/document-targets.ts +++ b/packages/core/src/document-targets.ts @@ -22,6 +22,8 @@ * identifiers equal between a full run and a targeted one. */ +import { Err, Ok } from "effection"; +import type { Result } from "effection"; import { remark } from "remark"; import { toString as mdastToString } from "mdast-util-to-string"; @@ -66,6 +68,12 @@ export interface DocumentOutline { /** Why a requested target did not resolve to exactly one catalog entry. */ export type DocumentTargetErrorKind = "invalid-selector" | "no-match" | "multiple-matches"; +const KINDS: readonly DocumentTargetErrorKind[] = [ + "invalid-selector", + "no-match", + "multiple-matches", +]; + const KIND_WORDING: ReadonlyMap = new Map([ ["invalid-selector", "is not a valid document target selector"], ["no-match", "matches no document target"], @@ -73,20 +81,25 @@ const KIND_WORDING: ReadonlyMap = new Map([ ]); /** - * A requested document target that does not name exactly one section. + * The structural tag a document-target failure carries. * - * An ordinary invocation failure: the caller asked for something the document - * does not offer, and nothing durable or contained is involved. It is raised - * before the document expands, so a run that cannot decide what to execute - * executes nothing. + * Namespaced and stable, because it is the whole recognition mechanism. Two + * loaded copies of this package are two classes, so `instanceof` answers false + * between them; a failure built by one copy has to be recognized by the other + * on exactly the same terms as one built here (AGENTS.md rule 15). + */ +const DOCUMENT_TARGET_FAILURE = "executablemd.document-target-failure"; + +/** + * Why one requested selector did not name exactly one section, as data. * - * Everything it carries is rebuilt and frozen here. The selector arrives from a - * command line and the catalog from a parser, and neither object belongs to a - * failure that outlives them. Every reference in the message is canonically - * encoded, so a heading holding a control character cannot reach a diagnostic - * literally. + * Frozen and rebuilt from validated parts wherever it crosses a boundary. The + * selector is retained because reproducing an ordinary failed execution needs + * to say what was asked for — it is sanitized invocation metadata, never + * identity, and it never stands in for an exact target. */ -export class DocumentTargetError extends Error { +export interface DocumentTargetFailure { + readonly type: typeof DOCUMENT_TARGET_FAILURE; readonly kind: DocumentTargetErrorKind; /** The selector fragment as it was requested, still encoded. */ readonly selector: string; @@ -94,29 +107,287 @@ export class DocumentTargetError extends Error { readonly matches: readonly string[]; /** Every canonical encoded target the document offers. */ readonly available: readonly string[]; +} - constructor( - kind: DocumentTargetErrorKind, - selector: string, - matches: readonly string[], - available: readonly string[], - ) { - const listed = kind === "multiple-matches" ? matches : available; - const heading = kind === "multiple-matches" ? "Matched targets:" : "Available targets:"; - super( - `${JSON.stringify(selector)} ${KIND_WORDING.get(kind)}.\n` + - (listed.length === 0 - ? "The document has no targets." - : `${heading}\n${listed.map((target) => ` ${target}`).join("\n")}`), - ); +/** The fields a failure carries, without the tag that authenticates them. */ +interface TargetFailureFields { + kind: DocumentTargetErrorKind; + selector: string; + matches: string[]; + available: string[]; +} + +function attempt(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Whether this is an Error, without trusting its prototype chain. */ +function isError(value: unknown): value is Error { + return attempt(() => value instanceof Error) === true; +} + +/** One property, read through a trap that may refuse or fail. */ +function property(target: object, name: string): unknown { + return attempt(() => Reflect.get(target, name)); +} + +function stringList(value: unknown): string[] | undefined { + return attempt(() => { + if (!Array.isArray(value)) { + return undefined; + } + const items: string[] = []; + for (let index = 0; index < value.length; index++) { + const item = index in value ? value[index] : undefined; + if (typeof item !== "string") { + return undefined; + } + items.push(item); + } + return items; + }); +} + +/** + * Read a candidate's failure fields, rebuilding every one of them. + * + * Total: an unreadable property, a missing one, a kind outside the closed set, + * a sparse or non-string list, and matches on a kind that has none are all "not + * this shape" rather than a throw. Nothing the candidate owns is retained — the + * arrays that come back are new. + */ +function targetFailureFields(value: unknown): TargetFailureFields | undefined { + return attempt(() => { + if (!isRecord(value)) { + return undefined; + } + const kind = KINDS.find((candidate) => candidate === property(value, "kind")); + const selector = property(value, "selector"); + const matches = stringList(property(value, "matches")); + const available = stringList(property(value, "available")); + if (kind === undefined || typeof selector !== "string") { + return undefined; + } + if (matches === undefined || available === undefined) { + return undefined; + } + // `matches` is the ambiguity list and nothing else; a populated one under + // any other kind is not the closed shape this contract describes. + if (kind !== "multiple-matches" && matches.length > 0) { + return undefined; + } + return { kind, selector, matches, available }; + }); +} + +/** Freeze validated fields into the failure data an error carries. */ +function sealFailure(fields: TargetFailureFields): DocumentTargetFailure { + return Object.freeze({ + type: DOCUMENT_TARGET_FAILURE, + kind: fields.kind, + selector: fields.selector, + matches: Object.freeze([...fields.matches]), + available: Object.freeze([...fields.available]), + }); +} + +/** + * The failure data this value carries, if it carries valid, tagged, frozen + * data. + * + * Every field is checked, the member count with them, and that the object is + * frozen: extra keys are not the shape this contract describes, and a mutable + * one is not the shape a constructor here produces. + */ +export function parseDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined { + return attempt(() => { + if (!isRecord(value) || property(value, "type") !== DOCUMENT_TARGET_FAILURE) { + return undefined; + } + if (attempt(() => Object.isFrozen(value)) !== true) { + return undefined; + } + if (attempt(() => Object.keys(value).length) !== 5) { + return undefined; + } + const fields = targetFailureFields(value); + return fields === undefined ? undefined : sealFailure(fields); + }); +} + +/** + * The failure a journal record describes, rebuilt and sealed. + * + * The record is untagged — its place inside a recorded root-import selection is + * what identifies it — so this validates the fields and supplies the tag, + * rather than requiring a tag the journal never held. + */ +export function recordedDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined { + const fields = targetFailureFields(value); + return fields === undefined ? undefined : sealFailure(fields); +} + +/** + * The one diagnostic a failure carries, derived from its data alone. + * + * Recognition compares against this, so the message cannot disagree with the + * fields. Every reference is canonically encoded and the selector is JSON + * quoted, so a heading holding a control character cannot reach a diagnostic + * literally. + */ +function documentTargetMessage(failure: DocumentTargetFailure): string { + const ambiguous = failure.kind === "multiple-matches"; + const listed = ambiguous ? failure.matches : failure.available; + const heading = ambiguous ? "Matched targets:" : "Available targets:"; + return ( + `${JSON.stringify(failure.selector)} ${KIND_WORDING.get(failure.kind)}.\n` + + (listed.length === 0 + ? "The document has no targets." + : `${heading}\n${listed.map((target) => ` ${target}`).join("\n")}`) + ); +} + +/** + * Everything a constructor here puts on the Error itself, and nothing else. + * + * `message` and `stack` are non-enumerable own properties of every Error, so + * what remains enumerable is exactly what this constructor assigned. + */ +const FAILURE_MEMBERS: readonly string[] = ["data", "name"]; + +function hasOnlyContractMembers(error: Error): boolean { + const keys = attempt(() => [...Object.keys(error)].sort()); + if (keys === undefined || keys.length !== FAILURE_MEMBERS.length) { + return false; + } + if (!keys.every((key, index) => key === FAILURE_MEMBERS[index])) { + return false; + } + const payload = attempt(() => + Object.getOwnPropertySymbols(error).filter( + (symbol) => Object.getOwnPropertyDescriptor(error, symbol)?.enumerable === true, + ), + ); + return payload !== undefined && payload.length === 0; +} + +/** + * A requested document target that does not name exactly one section. + * + * An ordinary invocation failure: the caller asked for something the document + * does not offer, and nothing durable or contained is involved. It is raised + * before the document expands, so a run that cannot decide what to execute + * executes nothing. + * + * Its data is the contract; the message is derived from it. Construct one from + * validated data — `documentTargetError()` — rather than from parts, so a + * failure rebuilt at a journal boundary is indistinguishable from the one the + * live run raised. + */ +export class DocumentTargetError extends Error { + readonly data: DocumentTargetFailure; + + constructor(data: DocumentTargetFailure) { + super(documentTargetMessage(data)); this.name = "DocumentTargetError"; - this.kind = kind; - this.selector = selector; - this.matches = Object.freeze([...matches]); - this.available = Object.freeze([...available]); + this.data = data; } } +/** Build the failure this selector produced, from parts this module owns. */ +export function documentTargetFailure( + kind: DocumentTargetErrorKind, + selector: string, + matches: readonly string[], + available: readonly string[], +): DocumentTargetFailure { + return sealFailure({ + kind, + selector, + matches: [...matches], + available: [...available], + }); +} + +/** + * Rebuild the error a failure describes. + * + * The one constructor call outside this module's own selection path, so a + * replayed failure and a live one are the same object shape carrying the same + * fields — a caller cannot tell which run raised it, and does not have to. + */ +export function documentTargetError(data: DocumentTargetFailure): DocumentTargetError { + return new DocumentTargetError(data); +} + +/** + * Whether this failure satisfies the whole contract, not merely the tag. + * + * Structural throughout, so a failure constructed by a separately loaded copy + * of this package is recognized on exactly the same terms as one constructed + * here. The name is checked rather than the class for the same reason: a second + * copy's constructor is a different function producing the same name. + * + * Stricter than `parseDocumentTargetFailure` because recognition hands the + * object onward: the message has to be the one its own data derives, there can + * be no cause, and no member beyond the contract — otherwise a candidate could + * carry a path, a foreign object, or a second message past this boundary under + * a recognized tag. + */ +export function isDocumentTargetError(error: unknown): error is DocumentTargetError { + return ( + attempt(() => { + if (!isError(error)) { + return false; + } + const data = parseDocumentTargetFailure(property(error, "data")); + if (data === undefined) { + return false; + } + if (property(error, "name") !== "DocumentTargetError") { + return false; + } + if (property(error, "message") !== documentTargetMessage(data)) { + return false; + } + if (property(error, "cause") !== undefined) { + return false; + } + return hasOnlyContractMembers(error); + }) === true + ); +} + +/** The document-target failure this error is, by identity. */ +export function asDocumentTargetError(error: unknown): DocumentTargetError | undefined { + return isDocumentTargetError(error) ? error : undefined; +} + +/** Whether two failures describe the same selection outcome, field by field. */ +export function sameDocumentTargetFailure( + left: DocumentTargetFailure, + right: DocumentTargetFailure, +): boolean { + return ( + left.kind === right.kind && + left.selector === right.selector && + sameList(left.matches, right.matches) && + sameList(left.available, right.available) + ); +} + +function sameList(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]); +} + const UNRESERVED = /^[A-Za-z0-9\-._~]$/; const HEX = /^[0-9A-Fa-f]$/; @@ -201,23 +472,26 @@ export function normalizeLabel(text: string): string { } /** - * Whether a fragment is already an exact canonical target: raw `/` between - * nonempty levels, every level percent-encoded exactly as this module encodes - * it, and no wildcard operator anywhere. + * Whether a fragment is already an exact canonical target. + * + * A level is canonical only when decoding it, normalizing the label, and + * re-encoding that label reproduce the level byte for byte. Requiring the whole + * round trip is what makes this total: it rejects a wildcard operator, an empty + * level, a lowercase escape, a raw `#`, an NFD spelling, a tab, and leading, + * trailing, or uncollapsed whitespace without naming any of them, because none + * of them is what this module would have written. */ export function isCanonicalTarget(target: string): boolean { if (target.length === 0) { return false; } return target.split("/").every((level) => { - if (level.length === 0 || level.includes("*")) { - return false; - } const decoded = decodePercentEncoded(level); if (decoded === undefined || decoded.length === 0) { return false; } - return encodeTargetLabel(decoded) === level; + const label = normalizeLabel(decoded); + return label === decoded && encodeTargetLabel(label) === level; }); } @@ -241,6 +515,12 @@ function parseSelector(selector: string): readonly SelectorLevel[] | undefined { if (selector.length === 0 || selector.startsWith("/") || selector.endsWith("/")) { return undefined; } + // A raw `#` is the reference's own delimiter, so it never reaches a selector + // by the supported route and cannot be written back into one. `%23` addresses + // a heading that really contains it. + if (selector.includes("#")) { + return undefined; + } const levels: SelectorLevel[] = []; for (const raw of selector.split("/")) { if (raw.length === 0) { @@ -347,24 +627,37 @@ function matchPath(levels: readonly SelectorLevel[], path: readonly string[]): b * duplicate entries, which is what makes that ambiguity observable at all. */ export function selectTarget(outline: DocumentOutline, selector: string): DocumentTarget { + const found = findTarget(outline, selector); + if (found.ok) { + return found.value; + } + throw found.error; +} + +/** The entry a selector names, or the failure describing why it names none. */ +export function findTarget(outline: DocumentOutline, selector: string): Result { + const fail = ( + kind: DocumentTargetErrorKind, + matches: readonly string[], + ): Result => + Err(documentTargetError(documentTargetFailure(kind, selector, matches, outline.targets))); + const levels = parseSelector(selector); if (levels === undefined) { - throw new DocumentTargetError("invalid-selector", selector, [], outline.targets); + return fail("invalid-selector", []); } const matched = outline.entries.filter((entry) => matchPath(levels, entry.labels)); const first = matched[0]; if (first === undefined) { - throw new DocumentTargetError("no-match", selector, [], outline.targets); + return fail("no-match", []); } if (matched.length > 1) { - throw new DocumentTargetError( + return fail( "multiple-matches", - selector, matched.map((entry) => entry.target), - outline.targets, ); } - return first; + return Ok(first); } /** diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index d15ae2ce..1e6bcd83 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -52,6 +52,14 @@ import { parseRootMarkdownDefinition, resolveDocumentTarget, } from "./definition.ts"; +import { + asDocumentTargetError, + documentTargetError, + documentTargetFailure, + recordedDocumentTargetFailure, + sameDocumentTargetFailure, +} from "./document-targets.ts"; +import type { DocumentTargetFailure } from "./document-targets.ts"; import { parseReturnsDeclaration } from "./frontmatter.ts"; import { expandSegments, @@ -138,8 +146,39 @@ export type ExecuteOptions = RootDocumentSource & ExecuteSettings; */ type DurableSelection = | { kind: "repository"; path: string; content: string; target?: string } + | { kind: "target-failure"; path: string; content: string; failure: TargetFailureRecord } | { kind: "registered"; origin: string; reserved: boolean }; +/** + * A selection that named no single section, as the journal holds it. + * + * A failed selection is an observation of the document, not an accident: the + * text was read, and it does not offer what was asked for. Recording it as data + * — rather than letting the effect fail and keeping only a serialized message — + * is what lets a resumed run tell "the same request, failing the same way" from + * "a different request the recorded run never made", and what lets the failure + * be rebuilt with its fields intact instead of reduced to prose. + * + * `selector` is sanitized invocation metadata. It is never identity: it does + * not occupy the exact-target field, and it never reaches a workflow + * definition. + */ +type TargetFailureRecord = { + kind: string; + selector: string; + matches: string[]; + available: string[]; +}; + +function targetFailureRecord(failure: DocumentTargetFailure): TargetFailureRecord { + return { + kind: failure.kind, + selector: failure.selector, + matches: [...failure.matches], + available: [...failure.available], + }; +} + function* durableImportComponent( name: string, root: RootDocumentSource | undefined, @@ -161,13 +200,22 @@ function* durableImportComponent( // for, not what ran. const path = rootSourcePath(root); const content = yield* readRootSource(root); - const target = - root.target === undefined ? undefined : resolveDocumentTarget(path, content, root.target); + if (root.target === undefined) { + return { kind: "repository", path, content }; + } + const resolved = resolveDocumentTarget(path, content, root.target); + if (resolved.ok) { + return { kind: "repository", path, content, target: resolved.value }; + } + const failure = asDocumentTargetError(resolved.error); + if (failure === undefined) { + throw resolved.error; + } return { - kind: "repository", + kind: "target-failure", path, content, - ...(target === undefined ? {} : { target }), + failure: targetFailureRecord(failure.data), }; } @@ -196,6 +244,20 @@ function* durableImportComponent( }, )) as DurableSelection; + // Rebuilt here rather than carried out of the durable operation, so a replayed + // failed selection and a live one raise the same error with the same fields. + // Parsed rather than trusted: the record is journal data. + if (selection.kind === "target-failure") { + const failure = recordedDocumentTargetFailure(selection.failure); + if (failure === undefined) { + throw new Error( + "The recorded root document import describes a failed target selection this version " + + "cannot read.", + ); + } + throw documentTargetError(failure); + } + if (selection.kind === "registered") { // The function was never journaled. Find the implementation the recorded // origin names in the registry this run has; refusing when it is gone is @@ -271,8 +333,24 @@ function isFunctionComponent(value: unknown): value is FunctionComponent { return typeof value === "function"; } +/** + * What one run's selector decided: the whole document, one exact section, or a + * failure that named none. + * + * Selection is compared as an outcome rather than as a target string, because a + * failed selection is an outcome too. Without the third case a journal written + * by one selector that matched nothing would answer a later request for a + * section that does exist. + */ +type SelectionOutcome = + | { kind: "whole" } + | { kind: "exact"; target: string } + | { kind: "failed"; failure: DocumentTargetFailure }; + /** The recorded root import this event is, when it is one that can be read. */ -function recordedRootImport(event: Yield): { content: string; target?: string } | undefined { +function recordedRootImport( + event: Yield, +): { content: string; selection: SelectionOutcome } | undefined { if ( event.description.type !== "import_component" || event.description.name !== "__root__" || @@ -285,99 +363,111 @@ function recordedRootImport(event: Yield): { content: string; target?: string } return undefined; } const content = record["content"]; - const target = record["target"]; - if (typeof content !== "string" || (target !== undefined && typeof target !== "string")) { + if (typeof content !== "string") { return undefined; } - return target === undefined ? { content } : { content, target }; + if (record["kind"] === "target-failure") { + const failure = recordedDocumentTargetFailure(record["failure"]); + return failure === undefined ? undefined : { content, selection: { kind: "failed", failure } }; + } + const target = record["target"]; + if (target === undefined) { + return { content, selection: { kind: "whole" } }; + } + return typeof target === "string" ? { content, selection: { kind: "exact", target } } : undefined; +} + +/** What this run's selector decides against the content the journal recorded. */ +function requestedSelection(root: RootDocumentSource, content: string): SelectionOutcome { + if (root.target === undefined) { + return { kind: "whole" }; + } + const resolved = resolveDocumentTarget(rootSourcePath(root), content, root.target); + if (resolved.ok) { + return { kind: "exact", target: resolved.value }; + } + const failure = asDocumentTargetError(resolved.error); + // A failure this module did not build is not a selection outcome that can be + // compared, so it cannot be shown compatible with anything. + return failure === undefined + ? { kind: "failed", failure: documentTargetFailure("invalid-selector", root.target, [], []) } + : { kind: "failed", failure: failure.data }; +} + +function sameSelection(recorded: SelectionOutcome, requested: SelectionOutcome): boolean { + if (recorded.kind === "whole" || requested.kind === "whole") { + return recorded.kind === requested.kind; + } + if (recorded.kind === "exact" || requested.kind === "exact") { + return ( + recorded.kind === "exact" && + requested.kind === "exact" && + recorded.target === requested.target + ); + } + return sameDocumentTargetFailure(recorded.failure, requested.failure); +} + +function describeSelection(selection: SelectionOutcome): string { + switch (selection.kind) { + case "whole": + return "the whole document"; + case "exact": + return `the target ${JSON.stringify(selection.target)}`; + case "failed": + return `a selector that names no single target (${selection.failure.kind})`; + } } /** - * Refuse to replay a run that was recorded against a different section. + * Hold a resumed run to the selection its journal recorded. * * Only `type` and `name` decide whether a journal entry matches, and the root - * import's name is the same for every target — so without this, resuming with a - * different selector would restore the recorded content and then project a - * section the recorded run never executed. + * import's name is the same for every selector — so without this, resuming with + * a different one would restore the recorded content and then project a section + * the recorded run never executed, or restore a recorded selection failure as + * the answer to a request that would have succeeded. * - * The current selector is resolved against the *recorded* content, so a glob - * that still names the same section replays and a glob that now names another - * one does not. A selector that has become invalid or ambiguous against that - * content is refused for the same reason: nothing here may guess which section - * a resumed run meant. + * The current selector is resolved against the *recorded* content, so what is + * compared is what each run decided, not what each caller typed: a different + * glob naming the same section replays, and so does the same failing selector, + * while any difference in outcome is stale input. * - * This validates in the check phase rather than the decide phase because - * `durableRun` reuses a recorded root Close before any effect is replayed. A - * decision made later would never run for a completed journal, which is exactly - * the run whose recorded target must still be the one being asked for. + * A recorded failed selection is reproduced here, not delegated. Nothing later + * would reproduce it with its fields intact — `durableRun` reuses a recorded + * root Close before any effect is replayed, and that path restores a + * deserialized error — so a recorded failure is rebuilt from its structural + * record and raised before that reuse. Either way no authored effect runs. * - * A `StaleInputError`, so it propagates as a durability failure rather than - * being printed into the document. + * This is also why validation is in the check phase rather than the decide + * phase: a decision made during replay never runs for a completed journal, + * which is exactly the run whose recorded selection must still be the one being + * asked for. */ -function refuseChangedRootTarget(root: RootDocumentSource): Operation { +function holdRootSelection(root: RootDocumentSource): Operation { return ReplayGuard.around({ *check([event], next) { const recorded = recordedRootImport(event); if (recorded === undefined) { return yield* next(event); } - const requested = resolveRecordedTarget(root, recorded.content); - const compatible = - requested.kind === "whole" - ? recorded.target === undefined - : requested.kind === "exact" && requested.target === recorded.target; - if (!compatible) { - const stale = new StaleInputError( - `the recorded root document import ran ${describeRecorded(recorded.target)}, and this ` + - `run asks for ${describeRequested(requested)}. Re-run the document from the ` + - "start rather than resuming from a journal that recorded another section.", + const requested = requestedSelection(root, recorded.content); + if (!sameSelection(recorded.selection, requested)) { + throw new StaleInputError( + `the recorded root document import ran ${describeSelection(recorded.selection)}, and ` + + `this run asks for ${describeSelection(requested)}. Re-run the document from the ` + + "start rather than resuming from a journal that recorded another selection.", { coroutineId: event.coroutineId, description: event.description }, ); - if (requested.kind === "unresolved") { - stale.cause = requested.failure; - } - throw stale; + } + if (recorded.selection.kind === "failed") { + throw documentTargetError(recorded.selection.failure); } return yield* next(event); }, }); } -/** What this run's selector names in the recorded content. */ -type RequestedTarget = - | { kind: "whole" } - | { kind: "exact"; target: string } - | { kind: "unresolved"; failure: unknown }; - -function resolveRecordedTarget(root: RootDocumentSource, content: string): RequestedTarget { - if (root.target === undefined) { - return { kind: "whole" }; - } - try { - return { - kind: "exact", - target: resolveDocumentTarget(rootSourcePath(root), content, root.target), - }; - } catch (failure) { - return { kind: "unresolved", failure }; - } -} - -function describeRecorded(target: string | undefined): string { - return target === undefined ? "the whole document" : `the target ${JSON.stringify(target)}`; -} - -function describeRequested(requested: RequestedTarget): string { - switch (requested.kind) { - case "whole": - return "the whole document"; - case "exact": - return `the target ${JSON.stringify(requested.target)}`; - case "unresolved": - return "a target that recorded content no longer names exactly once"; - } -} - const execFactory: ModifierFactory = (_params) => (_args, _next) => (function* () { const context = yield* useCodeBlock(); @@ -998,7 +1088,7 @@ function* executeDocument(options: ExecuteOptions): Operation // Installed before the durable run, so the check phase sees the recorded // root import before `durableRun` can reuse a recorded Close. - yield* refuseChangedRootTarget(root); + yield* holdRootSelection(root); // The policy is selected here — before the durable run and before any // document, frontmatter, prop, component, or eval code exists — so the diff --git a/packages/core/src/root-source.ts b/packages/core/src/root-source.ts index 15001889..51503a12 100644 --- a/packages/core/src/root-source.ts +++ b/packages/core/src/root-source.ts @@ -87,13 +87,22 @@ export function formatDocumentReference(path: string, target?: string): string { if (path.length === 0) { throw new TypeError(INVALID_REFERENCE); } + // The round trip is the rule, not a sample of it: a path only formats when + // decoding what this would write reproduces it exactly. NUL, which the + // decoder refuses, and an unpaired surrogate, which encodes lossily to the + // replacement character, both fail here rather than producing a reference + // that names a different file than the one asked about. + const encoded = encodeDocumentPath(path); + if (decodePercentEncoded(encoded) !== path) { + throw new TypeError(INVALID_REFERENCE); + } if (target === undefined) { - return encodeDocumentPath(path); + return encoded; } if (!isCanonicalTarget(target)) { throw new TypeError(INVALID_REFERENCE); } - return `${encodeDocumentPath(path)}#${target}`; + return `${encoded}#${target}`; } /** The identity printed errors and source positions report for this root. */ diff --git a/packages/core/tests/document-target-execution.test.ts b/packages/core/tests/document-target-execution.test.ts index 0236bbde..8df04fdf 100644 --- a/packages/core/tests/document-target-execution.test.ts +++ b/packages/core/tests/document-target-execution.test.ts @@ -29,7 +29,11 @@ import { execute } from "../src/execute.ts"; import { inspectDocument } from "../src/inspect.ts"; import { getExpansion } from "../src/expansion.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { DocumentTargetError } from "../src/document-targets.ts"; +import { + asDocumentTargetError, + DocumentTargetError, + isDocumentTargetError, +} from "../src/document-targets.ts"; import { fileSource, formatDocumentReference, inlineSource } from "../src/root-source.ts"; import type { RootDocumentSource } from "../src/root-source.ts"; import { asText } from "./helpers.ts"; @@ -361,11 +365,16 @@ describe("Tier TX — targeted execution", () => { const stream = new InMemoryStream(); const seen: Probes = { names: [], ids: [] }; const error = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream, seen); - expect((error as Error).name).toBe("DocumentTargetError"); - expect((error as Error).message).toContain("matches no document target"); + expect(isDocumentTargetError(error)).toBe(true); + expect(asDocumentTargetError(error)?.data).toMatchObject({ + kind: "no-match", + selector: "Missing", + matches: [], + }); expect(seen.names).toEqual([]); - // The root import is the only effect the journal saw, and it failed. - expect(rootImports(stream).map((event) => event.result.status)).toEqual(["err"]); + // The selection was recorded as an observation, and the effect succeeded: + // what failed is the document, deterministically, from that record. + expect(rootImports(stream).map((event) => event.result.status)).toEqual(["ok"]); expect(stream.snapshot().filter((event) => event.type === "yield").length).toBe(1); }); @@ -373,7 +382,7 @@ describe("Tier TX — targeted execution", () => { const stream = new InMemoryStream(); const seen: Probes = { names: [], ids: [] }; const error = yield* failure(inlineSource(SECTIONS, { target: "**" }), stream, seen); - expect((error as Error).message).toContain("matches more than one document target"); + expect(asDocumentTargetError(error)?.data.kind).toBe("multiple-matches"); expect(seen.names).toEqual([]); expect(stream.snapshot().filter((event) => event.type === "yield").length).toBe(1); }); @@ -464,7 +473,78 @@ describe("Tier TX — targeted replay", () => { // that it means the recorded section. const error = yield* failure(inlineSource(SECTIONS, { target: "**" }), stream); expect(error).toBeInstanceOf(StaleInputError); - expect((error as Error).cause).toBeInstanceOf(DocumentTargetError); + // Stale input is what this is, and it carries nothing else: the guard + // retains no failure object from the selection it could not match. + expect((error as Error).cause).toBe(undefined); + expect(isDocumentTargetError(error)).toBe(false); + }); + + /** + * The defect this stack shipped first, and the reason a failed selection is + * recorded structurally rather than left to the effect's own failure. + * + * `Missing` matched nothing, so the run failed and `durableRun` closed the + * root. A later request for `Good` — which the document really offers — was + * then answered with the recorded `Missing` error, because the guard + * delegated past every `err` result and the completed Close short-circuited + * everything after it. + */ + it("TX24: a journal from a failed selector never answers a valid one", function* () { + const stream = new InMemoryStream(); + const first = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream); + expect(asDocumentTargetError(first)?.data.selector).toBe("Missing"); + + const second = yield* failure(inlineSource(SECTIONS, { target: "Beta" }), stream); + expect(second).toBeInstanceOf(StaleInputError); + expect((second as Error).message).not.toContain("Missing"); + }); + + it("TX25: the same failing selector replays its own recorded failure", function* () { + const stream = new InMemoryStream(); + const seen: Probes = { names: [], ids: [] }; + const first = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream); + const replayed = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream, seen); + + expect(isDocumentTargetError(replayed)).toBe(true); + expect(asDocumentTargetError(replayed)?.data).toEqual(asDocumentTargetError(first)?.data); + expect(seen.names).toEqual([]); + // One recorded import, and it is the first run's. + expect(rootImports(stream).length).toBe(1); + }); + + it("TX26: one failed selection never answers for another kind of failure", function* () { + const stream = new InMemoryStream(); + yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream); + + // Ambiguous rather than unmatched: a different outcome, not a different + // spelling of the same one. + const ambiguous = yield* failure(inlineSource(SECTIONS, { target: "**" }), stream); + expect(ambiguous).toBeInstanceOf(StaleInputError); + + // Invalid syntax rather than unmatched. + const invalid = yield* failure(inlineSource(SECTIONS, { target: "/bad" }), stream); + expect(invalid).toBeInstanceOf(StaleInputError); + + // A different selector that also matches nothing is still a different + // request, and the recorded failure describes the one that was made. + const other = yield* failure(inlineSource(SECTIONS, { target: "AlsoMissing" }), stream); + expect(other).toBeInstanceOf(StaleInputError); + }); + + it("TX27: live and replayed selection failures are the same structural error", function* () { + const stream = new InMemoryStream(); + const live = yield* failure(inlineSource(SECTIONS, { target: "**/N*/Deep" }), stream); + const replayed = yield* failure(inlineSource(SECTIONS, { target: "**/N*/Deep" }), stream); + + for (const error of [live, replayed]) { + expect(isDocumentTargetError(error)).toBe(true); + expect(asDocumentTargetError(error)?.data.selector).toBe("**/N*/Deep"); + expect(asDocumentTargetError(error)?.data.available).toEqual([ + "Alpha", + "Alpha/Inner", + "Beta", + ]); + } }); it("TX22: an untargeted journal still replays for an untargeted run", function* () { diff --git a/packages/core/tests/document-targets.test.ts b/packages/core/tests/document-targets.test.ts index 92cedfc0..9905c7ba 100644 --- a/packages/core/tests/document-targets.test.ts +++ b/packages/core/tests/document-targets.test.ts @@ -24,10 +24,13 @@ import { expect } from "@executablemd/test-support/expect"; import type { Operation } from "effection"; import { + asDocumentTargetError, DocumentTargetError, encodeTargetLabel, isCanonicalTarget, + isDocumentTargetError, normalizeLabel, + parseDocumentTargetFailure, outlineDocument, retainedRanges, selectTarget, @@ -118,7 +121,7 @@ describe("Tier DT — document target catalog", () => { it("DT6: matching is case sensitive", function* () { const body = ["# Title", "", "## Test", ""].join("\n"); - expect(refusal(body, "test").kind).toBe("no-match"); + expect(refusal(body, "test").data.kind).toBe("no-match"); expect(selectTarget(outline(body), "Test").target).toBe("Test"); }); @@ -177,7 +180,7 @@ describe("Tier DT — document target catalog", () => { expect(catalog(body)).toEqual(["a%2Fb", "100%25%20done", "C%23%20sharp", "star%20%2A%20here"]); // `%2F` addresses one label containing a slash; a raw `/` would be hierarchy. expect(selectTarget(outline(body), "a%2Fb").labels).toEqual(["a/b"]); - expect(refusal(body, "a/b").kind).toBe("no-match"); + expect(refusal(body, "a/b").data.kind).toBe("no-match"); // `%2A` is a literal asterisk; a raw `*` is the operator. expect(selectTarget(outline(body), "star%20%2A%20here").labels).toEqual(["star * here"]); }); @@ -186,8 +189,8 @@ describe("Tier DT — document target catalog", () => { const body = ["# Title", "", "## Same", "", "one", "", "## Same", "", "two", ""].join("\n"); expect(catalog(body)).toEqual(["Same", "Same"]); const ambiguous = refusal(body, "Same"); - expect(ambiguous.kind).toBe("multiple-matches"); - expect(ambiguous.matches).toEqual(["Same", "Same"]); + expect(ambiguous.data.kind).toBe("multiple-matches"); + expect(ambiguous.data.matches).toEqual(["Same", "Same"]); }); it("DT12: only root-flow headings count", function* () { @@ -286,7 +289,7 @@ describe("Tier DT — document target catalog", () => { it("DT18: a document with no heading has an empty catalog", function* () { expect(catalog("just prose\n")).toEqual([]); - expect(refusal("just prose\n", "Anything").available).toEqual([]); + expect(refusal("just prose\n", "Anything").data.available).toEqual([]); }); it("DT19: a sole title is itself no target", function* () { @@ -297,7 +300,7 @@ describe("Tier DT — document target catalog", () => { describe("Tier DT — target selectors", () => { it("DT20: a literal selector matches one whole label", function* () { expect(selectTarget(outline(SECTIONS), "Test/Node").labels).toEqual(["Test", "Node"]); - expect(refusal(SECTIONS, "Nod").kind).toBe("no-match"); + expect(refusal(SECTIONS, "Nod").data.kind).toBe("no-match"); }); it("DT21: `*` matches within one level, in any position, more than once", function* () { @@ -306,7 +309,7 @@ describe("Tier DT — target selectors", () => { expect(selectTarget(outline(SECTIONS), "Test/N*d*").target).toBe("Test/Node"); expect(selectTarget(outline(SECTIONS), "*ther").target).toBe("Other"); // One `*` never crosses a level boundary. - expect(refusal(SECTIONS, "*Node").kind).toBe("no-match"); + expect(refusal(SECTIONS, "*Node").data.kind).toBe("no-match"); }); it("DT22: `**` matches zero or more complete levels", function* () { @@ -317,11 +320,16 @@ describe("Tier DT — target selectors", () => { }); it("DT23: a selector must name exactly one entry", function* () { - expect(refusal(SECTIONS, "**").kind).toBe("multiple-matches"); - expect(refusal(SECTIONS, "**").matches).toEqual(["Test", "Test/Node", "Test/Bun", "Other"]); - expect(refusal(SECTIONS, "Missing").kind).toBe("no-match"); - expect(refusal(SECTIONS, "Missing").matches).toEqual([]); - expect(refusal(SECTIONS, "Missing").available).toEqual([ + expect(refusal(SECTIONS, "**").data.kind).toBe("multiple-matches"); + expect(refusal(SECTIONS, "**").data.matches).toEqual([ + "Test", + "Test/Node", + "Test/Bun", + "Other", + ]); + expect(refusal(SECTIONS, "Missing").data.kind).toBe("no-match"); + expect(refusal(SECTIONS, "Missing").data.matches).toEqual([]); + expect(refusal(SECTIONS, "Missing").data.available).toEqual([ "Test", "Test/Node", "Test/Bun", @@ -331,7 +339,7 @@ describe("Tier DT — target selectors", () => { it("DT24: malformed selector syntax is refused as syntax", function* () { for (const selector of ["", "/Test", "Test/", "Test//Node", "%zz", "Test/%2"]) { - expect(refusal(SECTIONS, selector).kind).toBe("invalid-selector"); + expect(refusal(SECTIONS, selector).data.kind).toBe("invalid-selector"); } }); @@ -344,9 +352,9 @@ describe("Tier DT — target selectors", () => { }); it("DT26: a malformed or NUL-bearing escape never decodes", function* () { - expect(refusal(SECTIONS, "%00").kind).toBe("invalid-selector"); + expect(refusal(SECTIONS, "%00").data.kind).toBe("invalid-selector"); // A lone continuation byte is not UTF-8. - expect(refusal(SECTIONS, "%80").kind).toBe("invalid-selector"); + expect(refusal(SECTIONS, "%80").data.kind).toBe("invalid-selector"); }); /** @@ -358,7 +366,7 @@ describe("Tier DT — target selectors", () => { const label = "a".repeat(120); const body = ["# Title", "", `## ${label}`, ""].join("\n"); const selector = `${"*a".repeat(30)}*b`; - expect(refusal(body, selector).kind).toBe("no-match"); + expect(refusal(body, selector).data.kind).toBe("no-match"); expect(selectTarget(outline(body), `${"*a".repeat(30)}*`).labels).toEqual([label]); }); @@ -367,7 +375,7 @@ describe("Tier DT — target selectors", () => { const joined = ["# Title", "", "## alphabetagamma", ""].join("\n"); expect(selectTarget(outline(spaced), "alpha%20*%20gamma").labels).toEqual(["alpha beta gamma"]); // The spaces around the wildcard are part of what was asked for. - expect(refusal(joined, "alpha%20*%20gamma").kind).toBe("no-match"); + expect(refusal(joined, "alpha%20*%20gamma").data.kind).toBe("no-match"); // The level's own outer whitespace is not, so a padded selector still lands. expect(selectTarget(outline(spaced), "%20alpha*gamma%20").labels).toEqual(["alpha beta gamma"]); }); @@ -429,6 +437,79 @@ describe("Tier DT — canonical references", () => { expect(isCanonicalTarget("Test/%2A")).toBe(true); expect(isCanonicalTarget("Test/*")).toBe(false); }); + + /** + * Canonical means "exactly what the encoder would have written". Anything + * that decodes to a label needing normalization is a spelling of a target, + * not the target — accepting one would let two spellings of one section + * become two workflow-definition identities. + */ + it("DT48: a level is canonical only through the whole round trip", function* () { + for (const canonical of ["Caf%C3%A9", "a%20b", "A%2FB", "%2A", "%23", "a%2Bb", "Test/Node"]) { + expect(isCanonicalTarget(canonical)).toBe(true); + expect(formatDocumentReference("a.md", canonical)).toBe(`a.md#${canonical}`); + } + const rejected = [ + "Cafe%CC%81", // NFD — normalization would change it + "a%09b", // a tab is not an ASCII space + "a%20%20b", // uncollapsed whitespace + "%20a", // leading whitespace + "a%20", // trailing whitespace + "a%2fb", // lowercase escape + "A#B", // a raw `#` is the reference delimiter + "a//b", // an empty level + "a*b", // a raw wildcard operator + "%00", // NUL + ]; + for (const target of rejected) { + expect(isCanonicalTarget(target)).toBe(false); + } + }); + + it("DT49: a raw `#` is never a literal selector character, but `%23` is", function* () { + const body = ["# Title", "", "## A#B", "", "## Real", ""].join("\n"); + expect(catalog(body)).toEqual(["A%23B", "Real"]); + expect(selectTarget(outline(body), "A%23B").labels).toEqual(["A#B"]); + expect(refusal(body, "A#B").data.kind).toBe("invalid-selector"); + }); + + /** + * The formatter may only produce references the parser reads back. Sampling + * the rule would miss the two ways encoding loses information, so the + * implementation checks the round trip itself and these pin both losses. + */ + it("DT50: formatting refuses a path it could not encode losslessly", function* () { + // NUL, which the decoder refuses outright, and both halves of a broken + // surrogate pair, which encode lossily to the replacement character. + for (const path of ["a\u0000b.md", "lone\uD800.md", "trail\uDC00.md"]) { + let caught: unknown; + try { + formatDocumentReference(path); + } catch (error) { + caught = error; + } + expect((caught as Error | undefined)?.message).toBe("Invalid document reference"); + } + }); + + it("DT51: every formatted reference parses back to what it named", function* () { + const paths = [ + "README.md", + "docs/sub dir/a.md", + "odd#name.md", + "lit%20.md", + "café/ü.md", + "star*.md", + "a+b.md", + ]; + for (const path of paths) { + expect(fileSource(formatDocumentReference(path))).toEqual({ path }); + expect(fileSource(formatDocumentReference(path, "A%2FB"))).toEqual({ + path, + target: "A%2FB", + }); + } + }); }); describe("Tier DT — projection", () => { @@ -627,15 +708,15 @@ describe("Tier DT — inspection", () => { caught = error; } expect(caught).toBeInstanceOf(DocumentTargetError); - expect((caught as DocumentTargetError).kind).toBe("no-match"); - expect((caught as DocumentTargetError).selector).toBe("Nope"); + expect((caught as DocumentTargetError).data.kind).toBe("no-match"); + expect((caught as DocumentTargetError).data.selector).toBe("Nope"); }); it("DT47: the error's data is frozen and rebuilt, not the parser's arrays", function* () { const error = refusal(SECTIONS, "**"); - expect(Object.isFrozen(error.matches)).toBe(true); - expect(Object.isFrozen(error.available)).toBe(true); - expect(error.matches).not.toBe(outline(SECTIONS).targets); + expect(Object.isFrozen(error.data.matches)).toBe(true); + expect(Object.isFrozen(error.data.available)).toBe(true); + expect(error.data.matches).not.toBe(outline(SECTIONS).targets); // Encoded throughout, so a control character in a heading cannot reach a // diagnostic literally. expect(error.message).toContain('"**"'); @@ -644,3 +725,115 @@ describe("Tier DT — inspection", () => { } }); }); + +/** + * Recognition is the whole contract, so it is tested as one. + * + * A second loaded copy of this package is a different class producing the same + * name and the same tagged data, and it must be recognized on exactly the same + * terms. Everything else — a candidate carrying payload, a mutable data object, + * a message that disagrees with its own fields, a property that refuses to be + * read — must be refused, because recognition hands the object onward by + * identity and whatever it carries travels with it. + */ +describe("Tier DT — structural recognition", () => { + const FAILURE = Object.freeze({ + type: "executablemd.document-target-failure", + kind: "no-match", + selector: "Missing", + matches: Object.freeze([]), + available: Object.freeze(["Alpha"]), + }); + + const MESSAGE = '"Missing" matches no document target.\nAvailable targets:\n Alpha'; + + /** What a separately loaded copy of this module produces: same shape, own class. */ + function foreignError(): Error { + class DocumentTargetError extends Error { + readonly data = FAILURE; + constructor() { + super(MESSAGE); + this.name = "DocumentTargetError"; + } + } + return new DocumentTargetError(); + } + + it("DT52: a failure from another loaded copy is recognized", function* () { + const foreign = foreignError(); + expect(foreign instanceof DocumentTargetError).toBe(false); + expect(isDocumentTargetError(foreign)).toBe(true); + expect(asDocumentTargetError(foreign)?.data.kind).toBe("no-match"); + // Rebuilt, not adopted: the arrays a caller reads are this module's. + expect(parseDocumentTargetFailure(FAILURE)?.available).not.toBe(FAILURE.available); + }); + + it("DT53: this module's own failure is recognized", function* () { + expect(isDocumentTargetError(refusal(SECTIONS, "Missing"))).toBe(true); + }); + + it("DT54: every hostile or unreadable candidate is refused", function* () { + const withData = (data: unknown): Error => { + const error = new Error(MESSAGE); + error.name = "DocumentTargetError"; + Object.assign(error, { data }); + return error; + }; + const mutate = (change: Record) => Object.freeze({ ...FAILURE, ...change }); + + const hostile: unknown[] = [ + undefined, + null, + "a string", + new Error(MESSAGE), + // Untagged, wrongly tagged, unfrozen, and over- or under-populated data. + withData({ ...FAILURE }), + withData(mutate({ type: "other.tag" })), + withData(mutate({ kind: "made-up" })), + withData(Object.freeze({ ...FAILURE, extra: 1 })), + withData(Object.freeze({ type: FAILURE.type, kind: "no-match", selector: "Missing" })), + // A list holding something that is not a canonical reference. + withData(mutate({ available: Object.freeze([1]) })), + // `matches` populated under a kind that has none. + withData(mutate({ matches: Object.freeze(["Alpha"]) })), + // A property that refuses to answer. + withData( + Object.freeze( + Object.defineProperties( + { type: FAILURE.type, kind: "no-match", matches: [], available: [] }, + { + selector: { + get() { + throw new Error("hostile"); + }, + enumerable: true, + }, + }, + ), + ), + ), + ]; + for (const candidate of hostile) { + expect(isDocumentTargetError(candidate)).toBe(false); + expect(asDocumentTargetError(candidate)).toBe(undefined); + } + }); + + it("DT55: a recognized failure carries no cause and no extra payload", function* () { + const withCause = new Error(MESSAGE); + withCause.name = "DocumentTargetError"; + Object.assign(withCause, { data: FAILURE, cause: new Error("foreign") }); + expect(isDocumentTargetError(withCause)).toBe(false); + + const withPayload = new Error(MESSAGE); + withPayload.name = "DocumentTargetError"; + Object.assign(withPayload, { data: FAILURE, path: "/etc/passwd" }); + expect(isDocumentTargetError(withPayload)).toBe(false); + + // A message that does not derive from the data it claims. + const wrongMessage = new Error("something else"); + wrongMessage.name = "DocumentTargetError"; + Object.assign(wrongMessage, { data: FAILURE }); + expect(isDocumentTargetError(wrongMessage)).toBe(false); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 147f5515..7d02e736 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2676,6 +2676,13 @@ alone and escapes everything else as uppercase UTF-8 hexadecimal, so a `/`, `*`, `#`, or `%` inside a heading becomes `%2F`, `%2A`, `%23`, or `%25` and cannot be read as syntax. +A fragment is an **exact** canonical target only when every level survives the +whole round trip: decoding it, normalizing the label, and re-encoding that label +reproduce the level byte for byte. That one rule rejects a wildcard operator, an +empty level, a lowercase escape, a raw `#`, an NFD spelling, a tab, and leading, +trailing, or uncollapsed whitespace, because none of them is what the encoder +writes. Two spellings of one section are therefore never two identities. + The catalog is in source order and retains duplicates: two sections whose canonical paths are equal stay two entries, so the ambiguity is observable. @@ -2692,8 +2699,9 @@ The first raw `#` separates the two. Raw `/` separates target levels and raw chunks are percent-decoded, which is what keeps `%2F` a slash inside one label and `%2A` a literal asterisk. Decoding is URI path decoding: `+` is a plus, not a space. Malformed escapes, byte sequences that are not UTF-8, NUL, a leading -or trailing slash, and an empty level are all refused. Matching is -case-sensitive. +or trailing slash, an empty level, and a raw `#` — the reference's own +delimiter, written `%23` when a heading really contains one — are all refused. +Matching is case-sensitive. - A literal level matches one canonical label exactly, after decoding and label normalization. @@ -2751,15 +2759,17 @@ The live root import records the **exact canonical target**, never the caller's selector. An untargeted import records no target member at all, so journals written before targets existed stay readable by untargeted runs. -A replay guard validates the target before the recorded run is reused. It parses -the recorded root content, resolves the current selector against *that* content, -and requires the result to equal the recorded exact target; the recorded content -is then what the projection is taken from. A different selector naming the same -section replays. A different exact target, a targeted request against an -untargeted record, an untargeted request against a targeted record, and a -selector the recorded content no longer resolves are all stale input (§6.11). -The check runs before a completed run's recorded terminal result can be reused, -so a finished journal cannot answer for a section it never ran. +A replay guard validates the selection before the recorded run is reused. It +resolves the current selector against the *recorded* content and requires the +same selection outcome; the recorded content is then what the projection is +taken from. A different selector naming the same section replays, and so does +the same failing selector. A different exact target, a targeted request against +an untargeted record, an untargeted request against a targeted record, and any +difference in a failed selection are all stale input (§6.11). Stale input is +reported as itself: the guard retains no failure object from the selection it +could not match. The check runs before a completed run's recorded terminal +result can be reused, so a finished journal cannot answer for a selection it +never made. ##### Naming a root document @@ -2801,21 +2811,73 @@ than encoding it again, and joins them with `#`. It is the one formatter diagnostics, command output, and workflow handoff use. Making an authored glob canonical is the selector parser's work, not this function's. +It only formats what `fileSource()` reads back: the encoded path is decoded +again and must reproduce the path exactly. NUL, which the decoder refuses, and +an unpaired surrogate, which encodes lossily to the replacement character, are +therefore rejected rather than turned into a reference naming a different file. + Existing programmatic `{ path }` values and `inlineSource(source)` remain valid and untargeted. -An unresolvable target raises `DocumentTargetError`, whose `kind` is -`invalid-selector`, `no-match`, or `multiple-matches`. It carries the requested -`selector` as it arrived, the canonical encoded `matches` (empty except for -`multiple-matches`), and every canonical encoded `available` target. Its data is -rebuilt and frozen at the boundary, and its message quotes the selector as JSON -and lists canonical encoded references, so a heading holding a control character -cannot reach a diagnostic literally. It is an ordinary invocation failure, not a -durability or `API.Files` failure. Because target resolution sits inside the -durable root import, a failure reaching a caller through `execute()` arrives by -name and message like every other failure crossing that boundary; the typed -error is what `inspectDocument()` reports, and inspection is where a host -resolves a selector before running anything. +An unresolvable target raises `DocumentTargetError`. It is an ordinary +invocation failure, not a durability or `API.Files` failure, and it is the same +error on every public path: `inspectDocument()`, a live `execute()`, and a +replayed `execute()` all raise one carrying the same fields. + +```ts +interface DocumentTargetFailure { + readonly type: "executablemd.document-target-failure"; + readonly kind: "invalid-selector" | "no-match" | "multiple-matches"; + readonly selector: string; + readonly matches: readonly string[]; + readonly available: readonly string[]; +} + +class DocumentTargetError extends Error { + readonly data: DocumentTargetFailure; +} + +function isDocumentTargetError(error: unknown): error is DocumentTargetError; +function asDocumentTargetError(error: unknown): DocumentTargetError | undefined; +function parseDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined; +``` + +`selector` is the fragment as it arrived. `matches` is the ambiguity list and is +empty for every other kind; `available` is the whole catalog. The message is +derived from the data, quotes the selector as JSON, and lists canonical encoded +references, so a heading holding a control character cannot reach a diagnostic +literally. + +Recognition is structural and total. The data carries a stable namespaced tag, +so a failure built by a separately loaded copy of the package is recognized on +the same terms as one built locally — `instanceof` cannot answer that question +across two copies. Recognition also requires the name, the message its own data +derives, frozen data with exactly the described members, no cause, and no other +enumerable member: a recognized failure is handed onward by identity, so a +candidate carrying a path or a foreign object is refused rather than adopted. +The data is rebuilt from validated parts wherever it crosses a boundary, so +nothing a candidate owns is retained. + +##### A failed selection is recorded, not merely failed + +A selection that names no single section is an observation of the document: the +text was read, and it does not offer what was asked for. The root import records +that outcome structurally — its kind, the requested selector, the matches, and +the catalog — and the failure is then rebuilt from that record and raised. + +Recording it is what makes a resumed run correct. A journal is matched by effect +type and name alone, so without the record a run whose selector matched nothing +would leave a completed journal that answers a later request for a section that +does exist. The replay guard therefore compares whole selection outcomes — the +whole document, one exact target, or one failure — rather than target strings, +and reproduces a recorded failure with its fields intact before the recorded +terminal result can be reused. The same failing selector replays its own +failure; any difference in outcome, including a different selector that fails +the same way, is stale input. No authored effect runs in either case. + +The recorded selector is sanitized invocation metadata, retained only so an +ordinary failed execution can be reproduced. It never occupies the exact-target +field and never reaches a workflow definition. ### 5.5 The Component Api @@ -7278,6 +7340,51 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | WRR10/WRR10b | Outer rollback cache coherence | Failure and cancellation after an uncommitted removal and negative lookup roll back and invalidate both authoritative DOFS caches | | WRR11 | Historical file size | Every historical file entry's declared size agrees with its retained DOFS manifest during read-only recognition | +### Tier DT — Document target catalog, selectors, and projection + +| # | Test | Verify | +|---|------|--------| +| DT1–DT5 | Outline | ATX and Setext headings catalog in source order; a skipped depth still nests; the outermost depth is the smallest present; a sole outermost heading is the title and several are path levels | +| DT6 | Case | Matching is case-sensitive | +| DT7–DT9 | Labels | Formatting, link destinations, inline code, image alt text and passive HTML tags reduce to statically rendered text; a heading rendering no text is unaddressable | +| DT8 | Normalization | NFC-equivalent spellings are one label and Unicode whitespace collapses | +| DT10 | Encoding | `/`, `%`, `#` and `*` in a heading encode to `%2F`, `%25`, `%23` and `%2A` and never read as syntax | +| DT11 | Duplicates | Two sections with one canonical path stay two entries and report as ambiguous | +| DT12 | Nested flow | Headings in block quotes, lists, fences, exec fences and raw HTML are not targets | +| DT13 | Component children | A component child holding blank lines and `#` lines contributes no target — the regression that kills raw Remark discovery | +| DT14–DT17 | Addressability | A heading overlapping component syntax or carrying an interpolation is unaddressable and blocks its subtree; escaped interpolation stays static; a computed sole title still leaves its sections addressable | +| DT18/DT19 | Empty catalog | A document with no heading addresses nothing, and a sole title is no target | +| DT20–DT22 | Matching | Literal levels, embedded `*`, and `**` across zero or more levels | +| DT23 | Exactly one | Zero matches and several matches both fail, reporting matches and the catalog | +| DT24–DT26 | Selector syntax | Empty, leading/trailing slash, empty level, malformed escape, NUL and non-UTF-8 are refused; `+` is a plus | +| DT27 | Termination | A wildcard-dense selector against a long label completes without exponential search | +| DT28 | Wildcard whitespace | Whitespace beside a wildcard is matched; only the level's outer edges trim | +| DT29–DT32 | References | A reference splits at the first raw `#`; a path keeps separators and decodes escapes; an unreadable reference says only `Invalid document reference`, cause-free; formatting encodes the path and validates an exact target | +| DT33/DT48 | Canonical exactness | A level is canonical only through decode, normalize and re-encode — NFD, tabs, uncollapsed or edge whitespace, lowercase escapes, empty levels and raw operators are refused | +| DT49 | Raw `#` | A raw `#` is never a literal selector character; `%23` addresses a heading containing one | +| DT50/DT51 | Formatter totality | A path that cannot encode losslessly — NUL, an unpaired surrogate — is refused, and every formatted reference parses back to what it named | +| DT34–DT39 | Projection | Preamble, ancestor direct content and the selected subtree are retained; siblings are absent; a non-leaf keeps its descendants; a sole title stays | +| DT40–DT43 | Positions | A retained element keeps its authored offset and line, CRLF included; frontmatter, props and return mode survive; the untargeted parse still scans the whole body | +| DT44–DT47 | Inspection | The catalog is reported without selecting; a glob resolves to the exact target; an unresolvable target fails inspection; the failure's data is frozen and rebuilt | +| DT52–DT55 | Recognition | A failure from a separately loaded copy is recognized; hostile, unreadable, mutable, over-populated, cause-bearing and payload-bearing candidates are all refused | + +### Tier TX — Targeted execution and replay + +| # | Test | Verify | +|---|------|--------| +| TX1–TX3 | Selection | Only the preamble, ancestors and subtree expand; a skipped sibling's components and code blocks never run; a non-leaf expands its descendants | +| TX4/TX5 | Identity | A retained element keeps the expansion ID it has in a full run, and two targets retaining it agree | +| TX6 | Sources | A file root and an inline root behave identically | +| TX7–TX9 | Root values | Root props, frontmatter interpolation, a declared `returns`, and `` apply to the projected body | +| TX10/TX11 | Structure | An invalid skipped sibling is irrelevant; an invalid retained range fails before any authored effect | +| TX12–TX14 | Failure timing | An unmatched or ambiguous target runs no authored effect and is structurally recognizable; inspection resolves without expanding a component | +| TX15/TX16 | Recording | The journal records the exact target, never the glob, and an untargeted run records no target member | +| TX17 | Compatibility | A different selector naming the same section replays | +| TX18–TX21 | Staleness | A different exact target, a targeted request against an untargeted journal, the reverse, and a selector the recorded content no longer resolves all fail stale before completed-Close reuse, carrying no foreign cause | +| TX22/TX23 | Recorded content | An untargeted journal replays untargeted; replay projects the recorded content, not the file on disk | +| TX24 | Failed selection | A journal from a selector that matched nothing never answers a later valid one | +| TX25–TX27 | Failed replay | The same failing selector replays its own recorded failure with no authored effect; a different failure kind or a different selector is stale; live and replayed failures are the same structural error | + ### Tier SL — Own-scope context updates | # | Test | Verify | From 0422f608acad55b68a01044196897787c11c6d6f Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:47:50 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=20Fail=20closed=20on=20a=20rec?= =?UTF-8?q?orded=20root=20selection=20this=20version=20cannot=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Not the root import" and "the root import, malformed" were one absent value, so a corrupted record fell through to the recorded terminal result and replayed an outcome the record no longer describes. The recorded root import is now a closed protocol: a repository selection with an optional exact canonical target, or a failed selection with an exact failure record. An unknown kind, a missing, unreadable, mistyped or extra member, and a noncanonical target are malformed, and malformed fails with one fixed cause-free diagnostic before the recorded Close can be reused — delegating nothing, executing nothing, appending nothing. The record carries the content it was taken from, so the selection is verified against that content rather than merely parsed: a recorded target must resolve to itself, and a recorded failure must be the failure that selector produces. `asDocumentTargetError()` returns a fresh local error built from reconstructed data instead of the candidate. An ordinary invocation failure has no fail-stop reason to preserve identity, and returning the candidate hands on whatever it owns — a list its owner can still rewrite, a revocable Proxy, a prototype with accessors. The data contract is closed to exactly five members with no symbol or non-enumerable extras, every list entry must be an exact canonical target, and the fields must describe an outcome selection could have reached. --- architecture.md | 7 + packages/core/src/document-targets.ts | 224 +++++++++++---- packages/core/src/execute.ts | 121 ++++++-- .../tests/document-target-execution.test.ts | 155 ++++++++++ packages/core/tests/document-targets.test.ts | 268 +++++++++++++----- specs/executable-mdx-spec.md | 73 ++++- 6 files changed, 683 insertions(+), 165 deletions(-) diff --git a/architecture.md b/architecture.md index fa7c86d1..c2a1fb6e 100644 --- a/architecture.md +++ b/architecture.md @@ -572,6 +572,13 @@ selection is an outcome too, and is recorded and compared as one — otherwise a journal left by a selector that matched nothing would answer a later request for a section that does exist. +A recorded selection is a closed protocol, and a record that does not satisfy it +is refused rather than delegated. "This event is not the root import" and "the +root import, malformed" are different answers: one continues, the other fails +before the recorded terminal result can be reused, without executing authored +work or appending history. The record carries the content it was taken from, so +the selection is verified against that content rather than merely parsed. + ## Expansion identity Core describes the executable element currently being expanded: diff --git a/packages/core/src/document-targets.ts b/packages/core/src/document-targets.ts index 9329aab3..333f4861 100644 --- a/packages/core/src/document-targets.ts +++ b/packages/core/src/document-targets.ts @@ -156,13 +156,73 @@ function stringList(value: unknown): string[] | undefined { }); } +/** + * The canonical labels a canonical target encodes. + * + * Only called on an entry already proven canonical, so decoding cannot fail; + * the guard is here because this reads data a candidate supplied. + */ +function targetLabels(target: string): string[] | undefined { + const labels: string[] = []; + for (const level of target.split("/")) { + const decoded = decodePercentEncoded(level); + if (decoded === undefined) { + return undefined; + } + labels.push(decoded); + } + return labels; +} + +/** A dense list of canonical encoded targets, copied out of the candidate. */ +function targetList(value: unknown): string[] | undefined { + const items = stringList(value); + if (items === undefined) { + return undefined; + } + return items.every((item) => isCanonicalTarget(item)) ? items : undefined; +} + +/** + * Whether these fields describe an outcome selection could actually have + * reached. + * + * The check re-derives the outcome rather than trusting the three fields to + * agree: the selector is parsed, matched against the catalog the candidate + * supplied, and the result compared with the matches it claims. A record whose + * kind, selector, matches, and catalog cannot all be true at once is refused, + * so an inconsistent journal or a hand-built candidate cannot describe a + * selection that never happened. + */ +function consistentOutcome(fields: TargetFailureFields): boolean { + const levels = parseSelector(fields.selector); + if (fields.kind === "invalid-selector") { + return levels === undefined && fields.matches.length === 0; + } + if (levels === undefined) { + return false; + } + const derived = fields.available.filter((target) => { + const labels = targetLabels(target); + return labels !== undefined && matchPath(levels, labels); + }); + if (fields.kind === "no-match") { + return derived.length === 0 && fields.matches.length === 0; + } + return derived.length > 1 && sameList(derived, fields.matches); +} + /** * Read a candidate's failure fields, rebuilding every one of them. * * Total: an unreadable property, a missing one, a kind outside the closed set, - * a sparse or non-string list, and matches on a kind that has none are all "not - * this shape" rather than a throw. Nothing the candidate owns is retained — the - * arrays that come back are new. + * a sparse list, an entry that is not a canonical encoded target, and a + * combination of kind, selector, matches and catalog that no selection could + * have produced are all "not this shape" rather than a throw. + * + * Nothing the candidate owns is retained. Every entry is read once and copied + * into a fresh array, so a list that is mutated afterwards — or reached through + * a Proxy that is later revoked — cannot change what comes back. */ function targetFailureFields(value: unknown): TargetFailureFields | undefined { return attempt(() => { @@ -171,20 +231,16 @@ function targetFailureFields(value: unknown): TargetFailureFields | undefined { } const kind = KINDS.find((candidate) => candidate === property(value, "kind")); const selector = property(value, "selector"); - const matches = stringList(property(value, "matches")); - const available = stringList(property(value, "available")); + const matches = targetList(property(value, "matches")); + const available = targetList(property(value, "available")); if (kind === undefined || typeof selector !== "string") { return undefined; } if (matches === undefined || available === undefined) { return undefined; } - // `matches` is the ambiguity list and nothing else; a populated one under - // any other kind is not the closed shape this contract describes. - if (kind !== "multiple-matches" && matches.length > 0) { - return undefined; - } - return { kind, selector, matches, available }; + const fields = { kind, selector, matches, available }; + return consistentOutcome(fields) ? fields : undefined; }); } @@ -199,23 +255,49 @@ function sealFailure(fields: TargetFailureFields): DocumentTargetFailure { }); } +/** Exactly the members failure data describes, and nothing else. */ +const FAILURE_DATA_MEMBERS: readonly string[] = [ + "available", + "kind", + "matches", + "selector", + "type", +]; + +/** + * Whether this object's own members are exactly the contract's. + * + * Own *property names* rather than enumerable keys, and own symbols with them: + * a non-enumerable extra is still payload the contract does not describe, and a + * symbol-keyed one survives spreading and `Object.assign`, which are exactly the + * mechanisms a consumer uses to pass data on. + */ +function hasOnlyDataMembers(value: object): boolean { + const names = attempt(() => [...Object.getOwnPropertyNames(value)].sort()); + if (names === undefined || names.length !== FAILURE_DATA_MEMBERS.length) { + return false; + } + if (!names.every((name, index) => name === FAILURE_DATA_MEMBERS[index])) { + return false; + } + return attempt(() => Object.getOwnPropertySymbols(value).length) === 0; +} + /** - * The failure data this value carries, if it carries valid, tagged, frozen - * data. + * The failure data this value carries, if it carries valid, tagged data. * - * Every field is checked, the member count with them, and that the object is - * frozen: extra keys are not the shape this contract describes, and a mutable - * one is not the shape a constructor here produces. + * Every field is checked, the exact member set with them, and the outcome the + * fields describe is re-derived rather than trusted. What comes back is built + * here from validated parts: the candidate's own arrays are never adopted, so a + * nested list that is mutable, mutated later, or reached through a revocable + * Proxy cannot reach a caller. */ export function parseDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined { return attempt(() => { if (!isRecord(value) || property(value, "type") !== DOCUMENT_TARGET_FAILURE) { return undefined; } - if (attempt(() => Object.isFrozen(value)) !== true) { - return undefined; - } - if (attempt(() => Object.keys(value).length) !== 5) { + if (!hasOnlyDataMembers(value)) { return undefined; } const fields = targetFailureFields(value); @@ -223,16 +305,33 @@ export function parseDocumentTargetFailure(value: unknown): DocumentTargetFailur }); } +/** Exactly the members a journal's failure record holds: the data, untagged. */ +const RECORD_MEMBERS: readonly string[] = ["available", "kind", "matches", "selector"]; + /** * The failure a journal record describes, rebuilt and sealed. * * The record is untagged — its place inside a recorded root-import selection is * what identifies it — so this validates the fields and supplies the tag, - * rather than requiring a tag the journal never held. + * rather than requiring a tag the journal never held. The member set is closed + * all the same: an extra key is data this contract does not describe, and a + * journal is not a place to carry undescribed data forward from. */ export function recordedDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined { - const fields = targetFailureFields(value); - return fields === undefined ? undefined : sealFailure(fields); + return attempt(() => { + if (!isRecord(value)) { + return undefined; + } + const names = attempt(() => [...Object.getOwnPropertyNames(value)].sort()); + if (names === undefined || names.length !== RECORD_MEMBERS.length) { + return undefined; + } + if (!names.every((name, index) => name === RECORD_MEMBERS[index])) { + return undefined; + } + const fields = targetFailureFields(value); + return fields === undefined ? undefined : sealFailure(fields); + }); } /** @@ -329,46 +428,61 @@ export function documentTargetError(data: DocumentTargetFailure): DocumentTarget } /** - * Whether this failure satisfies the whole contract, not merely the tag. + * The validated failure data this error carries, if it satisfies the whole + * contract rather than merely the tag. * * Structural throughout, so a failure constructed by a separately loaded copy - * of this package is recognized on exactly the same terms as one constructed - * here. The name is checked rather than the class for the same reason: a second + * of this package is read on exactly the same terms as one constructed here. + * The name is checked rather than the class for the same reason: a second * copy's constructor is a different function producing the same name. * - * Stricter than `parseDocumentTargetFailure` because recognition hands the - * object onward: the message has to be the one its own data derives, there can - * be no cause, and no member beyond the contract — otherwise a candidate could - * carry a path, a foreign object, or a second message past this boundary under - * a recognized tag. + * The shell is checked as strictly as the data. The message has to be the one + * the data derives, so a diagnostic cannot disagree with the fields it claims; + * there can be no cause and no enumerable member beyond the contract, so a + * candidate cannot carry a path, a foreign object, or a second message under a + * recognized tag. */ -export function isDocumentTargetError(error: unknown): error is DocumentTargetError { - return ( - attempt(() => { - if (!isError(error)) { - return false; - } - const data = parseDocumentTargetFailure(property(error, "data")); - if (data === undefined) { - return false; - } - if (property(error, "name") !== "DocumentTargetError") { - return false; - } - if (property(error, "message") !== documentTargetMessage(data)) { - return false; - } - if (property(error, "cause") !== undefined) { - return false; - } - return hasOnlyContractMembers(error); - }) === true - ); +function readDocumentTargetError(error: unknown): DocumentTargetFailure | undefined { + return attempt(() => { + if (!isError(error)) { + return undefined; + } + const data = parseDocumentTargetFailure(property(error, "data")); + if (data === undefined) { + return undefined; + } + if (property(error, "name") !== "DocumentTargetError") { + return undefined; + } + if (property(error, "message") !== documentTargetMessage(data)) { + return undefined; + } + if (property(error, "cause") !== undefined) { + return undefined; + } + return hasOnlyContractMembers(error) ? data : undefined; + }); } -/** The document-target failure this error is, by identity. */ +/** Whether this failure satisfies the whole document-target contract. */ +export function isDocumentTargetError(error: unknown): boolean { + return readDocumentTargetError(error) !== undefined; +} + +/** + * The document-target failure this error describes, as a local error. + * + * A fresh error built from reconstructed data, never the candidate itself. + * Nothing here has a fail-stop's reason to preserve object identity — this is + * an ordinary invocation failure, and what a caller needs is the outcome, not + * the instance that reported it. Returning the candidate would hand on whatever + * it owns: nested arrays somebody else can still mutate, a revocable Proxy, a + * prototype with accessors. Rebuilding costs one allocation and removes all of + * it, so the result stays readable however the original is treated afterwards. + */ export function asDocumentTargetError(error: unknown): DocumentTargetError | undefined { - return isDocumentTargetError(error) ? error : undefined; + const data = readDocumentTargetError(error); + return data === undefined ? undefined : new DocumentTargetError(data); } /** Whether two failures describe the same selection outcome, field by field. */ diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 1e6bcd83..3263ecf6 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -56,6 +56,7 @@ import { asDocumentTargetError, documentTargetError, documentTargetFailure, + isCanonicalTarget, recordedDocumentTargetFailure, sameDocumentTargetFailure, } from "./document-targets.ts"; @@ -250,10 +251,7 @@ function* durableImportComponent( if (selection.kind === "target-failure") { const failure = recordedDocumentTargetFailure(selection.failure); if (failure === undefined) { - throw new Error( - "The recorded root document import describes a failed target selection this version " + - "cannot read.", - ); + throw new Error(UNREADABLE_ROOT_RECORD); } throw documentTargetError(failure); } @@ -347,34 +345,101 @@ type SelectionOutcome = | { kind: "exact"; target: string } | { kind: "failed"; failure: DocumentTargetFailure }; -/** The recorded root import this event is, when it is one that can be read. */ -function recordedRootImport( - event: Yield, -): { content: string; selection: SelectionOutcome } | undefined { - if ( - event.description.type !== "import_component" || - event.description.name !== "__root__" || - event.result.status !== "ok" - ) { - return undefined; +/** + * What the fixed diagnostic says when a recorded root import cannot be read, + * and all it says. + * + * Cause-free: the record is journal data, and quoting it back would put + * whatever it holds into a diagnostic. + */ +const UNREADABLE_ROOT_RECORD = "The recorded root document import cannot be read by this version."; + +/** + * What a recorded event turned out to be. + * + * "Not the root import" and "the root import, malformed" are deliberately + * different answers. Collapsing them into one absent value is what would let a + * corrupted record fall through to the recorded terminal result, which is the + * failure this distinction exists to prevent. + */ +type RootImportRecord = + | { kind: "unrelated" } + | { kind: "malformed" } + | { kind: "read"; content: string; selection: SelectionOutcome }; + +const UNRELATED: RootImportRecord = { kind: "unrelated" }; +const MALFORMED: RootImportRecord = { kind: "malformed" }; + +/** + * Parse a recorded root import as a closed protocol. + * + * Two selection shapes are supported and nothing else: a repository selection + * with an optional canonical target, and a failed selection with an exact + * failure record. An unknown kind, a missing or mistyped member, an extra + * member, a noncanonical target, and failure data that no selection could have + * produced are each malformed rather than absent. + * + * A result that is not `ok` is left alone. A root import can fail for reasons + * that have nothing to do with selection — an unreadable file — and those + * recorded failures are not this protocol's to interpret. + */ +function recordedRootImport(event: Yield): RootImportRecord { + if (event.description.type !== "import_component" || event.description.name !== "__root__") { + return UNRELATED; + } + if (event.result.status !== "ok") { + return UNRELATED; } const record = event.result.value; if (!isJsonObject(record)) { - return undefined; + return MALFORMED; } const content = record["content"]; - if (typeof content !== "string") { - return undefined; + const path = record["path"]; + if (typeof content !== "string" || typeof path !== "string") { + return MALFORMED; } - if (record["kind"] === "target-failure") { - const failure = recordedDocumentTargetFailure(record["failure"]); - return failure === undefined ? undefined : { content, selection: { kind: "failed", failure } }; + const kind = record["kind"]; + const members = Object.keys(record).length; + + if (kind === "repository") { + const target = record["target"]; + if (target === undefined) { + return members === 3 ? { kind: "read", content, selection: { kind: "whole" } } : MALFORMED; + } + if (members !== 4 || typeof target !== "string" || !isCanonicalTarget(target)) { + return MALFORMED; + } + // The recorded content is here, so the target is verified against it rather + // than merely parsed: a well-formed target the recorded document does not + // offer describes a selection that never happened. + const resolved = resolveDocumentTarget(path, content, target); + if (!resolved.ok || resolved.value !== target) { + return MALFORMED; + } + return { kind: "read", content, selection: { kind: "exact", target } }; } - const target = record["target"]; - if (target === undefined) { - return { content, selection: { kind: "whole" } }; + + if (kind === "target-failure") { + const failure = recordedDocumentTargetFailure(record["failure"]); + if (members !== 4 || failure === undefined) { + return MALFORMED; + } + // Same standard for a failure: the recorded selector must fail against the + // recorded content in exactly the way the record claims. That verifies the + // catalog and the matches too, which no amount of shape checking could. + const rederived = resolveDocumentTarget(path, content, failure.selector); + if (rederived.ok) { + return MALFORMED; + } + const actual = asDocumentTargetError(rederived.error); + if (actual === undefined || !sameDocumentTargetFailure(actual.data, failure)) { + return MALFORMED; + } + return { kind: "read", content, selection: { kind: "failed", failure } }; } - return typeof target === "string" ? { content, selection: { kind: "exact", target } } : undefined; + + return MALFORMED; } /** What this run's selector decides against the content the journal recorded. */ @@ -448,9 +513,15 @@ function holdRootSelection(root: RootDocumentSource): Operation { return ReplayGuard.around({ *check([event], next) { const recorded = recordedRootImport(event); - if (recorded === undefined) { + if (recorded.kind === "unrelated") { return yield* next(event); } + // Refused here, so a corrupted record can never reach the recorded + // terminal result. Nothing is delegated, nothing is executed, and no + // history is appended. + if (recorded.kind === "malformed") { + throw new Error(UNREADABLE_ROOT_RECORD); + } const requested = requestedSelection(root, recorded.content); if (!sameSelection(recorded.selection, requested)) { throw new StaleInputError( diff --git a/packages/core/tests/document-target-execution.test.ts b/packages/core/tests/document-target-execution.test.ts index 8df04fdf..3b529b70 100644 --- a/packages/core/tests/document-target-execution.test.ts +++ b/packages/core/tests/document-target-execution.test.ts @@ -34,6 +34,7 @@ import { DocumentTargetError, isDocumentTargetError, } from "../src/document-targets.ts"; +import { isJsonObject, parseJson } from "../src/json.ts"; import { fileSource, formatDocumentReference, inlineSource } from "../src/root-source.ts"; import type { RootDocumentSource } from "../src/root-source.ts"; import { asText } from "./helpers.ts"; @@ -576,3 +577,157 @@ describe("Tier TX — targeted replay", () => { expect(replayed).not.toContain("rewritten beta"); }); }); + +/** + * Tier TX — a corrupted root-import record fails closed. + * + * "Not the root import" and "the root import, malformed" have to be different + * answers. A boundary that returns one absent value for both delegates a + * corrupted record onward, and `durableRun` then reuses the recorded terminal + * result — replaying a failure or a success the record no longer describes. + * + * Every case here starts from a *valid* completed journal and corrupts only the + * recorded selection, so what is being measured is the parse and nothing else. + * Each is resumed twice: once with the selector that produced the journal, once + * with a selector that would succeed against a healthy record. Both must be + * refused with the fixed diagnostic, and neither may expand anything or append + * history. + */ +describe("Tier TX — malformed recorded selections", () => { + const UNREADABLE = "The recorded root document import cannot be read by this version."; + + /** A completed journal whose root import recorded a failed selection. */ + function* failedJournal(): Operation { + const stream = new InMemoryStream(); + yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream); + return stream; + } + + /** Rewrite the recorded root-import selection, keeping everything else. */ + function* corrupt( + stream: InMemoryStream, + change: (record: Record) => Record, + ): Operation { + const corrupted = new InMemoryStream(); + for (const event of stream.snapshot()) { + const record = + event.type === "yield" && + event.description.name === "__root__" && + event.result.status === "ok" + ? event.result.value + : undefined; + if (event.type === "yield" && isJsonObject(record)) { + yield* corrupted.append({ + ...event, + result: { status: "ok", value: parseJson(change({ ...record })) }, + }); + continue; + } + yield* corrupted.append(event); + } + return corrupted; + } + + /** Every way a corrupted record must be refused, resumed both ways. */ + function* refuses( + change: (record: Record) => Record, + ): Operation { + const healthy = yield* failedJournal(); + const before = (yield* corrupt(healthy, change)).snapshot().length; + + for (const target of ["Missing", "Beta"]) { + const stream = yield* corrupt(healthy, change); + const seen: Probes = { names: [], ids: [] }; + const error = yield* failure(inlineSource(SECTIONS, { target }), stream, seen); + + expect((error as Error).message).toBe(UNREADABLE); + expect((error as Error).cause).toBe(undefined); + // Not the recorded failure, and not a document-target failure at all. + expect(isDocumentTargetError(error)).toBe(false); + expect((error as Error).message).not.toContain("Missing"); + // Nothing expanded, and no history was appended on top of the corruption. + expect(seen.names).toEqual([]); + expect(stream.snapshot().length).toBe(before); + } + } + + it("TX28: a missing or non-array `available` is refused", function* () { + yield* refuses((record) => ({ + ...record, + failure: omit(record["failure"], "available"), + })); + yield* refuses((record) => ({ + ...record, + failure: { ...asRecord(record["failure"]), available: "Beta" }, + })); + }); + + it("TX29: an unknown selection kind is refused", function* () { + yield* refuses((record) => ({ ...record, kind: "something-else" })); + yield* refuses((record) => omit(record, "kind")); + }); + + it("TX30: extra data in the record or the failure is refused", function* () { + yield* refuses((record) => ({ ...record, extra: "payload" })); + yield* refuses((record) => ({ + ...record, + failure: { ...asRecord(record["failure"]), extra: "payload" }, + })); + }); + + it("TX31: a noncanonical target entry is refused", function* () { + for (const available of [["../../etc/passwd"], ["Beta "], ["a%2fb"]]) { + yield* refuses((record) => ({ + ...record, + failure: { ...asRecord(record["failure"]), available }, + })); + } + // The same rule on a successful repository selection's own target. + const healthy = new InMemoryStream(); + yield* run(inlineSource(SECTIONS, { target: "Beta" }), healthy, { names: [], ids: [] }); + const stream = yield* corrupt(healthy, (record) => ({ ...record, target: "beta" })); + const error = yield* failure(inlineSource(SECTIONS, { target: "Beta" }), stream); + expect((error as Error).message).toBe(UNREADABLE); + }); + + it("TX32: semantically inconsistent kind and matches are refused", function* () { + // `no-match` carrying matches, and a selector that really does match. + yield* refuses((record) => ({ + ...record, + failure: { ...asRecord(record["failure"]), matches: ["Beta"] }, + })); + yield* refuses((record) => ({ + ...record, + failure: { ...asRecord(record["failure"]), selector: "Beta" }, + })); + // `multiple-matches` with a single match. + yield* refuses((record) => ({ + ...record, + failure: { + ...asRecord(record["failure"]), + kind: "multiple-matches", + selector: "Beta", + matches: ["Beta"], + }, + })); + }); + + it("TX33: a valid record still replays, so the refusals are not vacuous", function* () { + const stream = yield* failedJournal(); + const seen: Probes = { names: [], ids: [] }; + const replayed = yield* failure(inlineSource(SECTIONS, { target: "Missing" }), stream, seen); + expect(isDocumentTargetError(replayed)).toBe(true); + expect((replayed as Error).message).not.toBe(UNREADABLE); + expect(seen.names).toEqual([]); + }); +}); + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? { ...(value as object) } : {}; +} + +function omit(value: unknown, key: string): Record { + const record = asRecord(value); + delete record[key]; + return record; +} diff --git a/packages/core/tests/document-targets.test.ts b/packages/core/tests/document-targets.test.ts index 9905c7ba..77d66f7d 100644 --- a/packages/core/tests/document-targets.test.ts +++ b/packages/core/tests/document-targets.test.ts @@ -729,28 +729,45 @@ describe("Tier DT — inspection", () => { /** * Recognition is the whole contract, so it is tested as one. * - * A second loaded copy of this package is a different class producing the same - * name and the same tagged data, and it must be recognized on exactly the same - * terms. Everything else — a candidate carrying payload, a mutable data object, - * a message that disagrees with its own fields, a property that refuses to be - * read — must be refused, because recognition hands the object onward by - * identity and whatever it carries travels with it. + * The boundary is closed and it reconstructs: a candidate is validated field by + * field and a *fresh local* error is built from the result. Nothing the + * candidate owns is handed on, which is why these mutate and revoke the + * originals afterwards and assert the answer is unchanged. + * + * A separately loaded copy of this package is a different class producing the + * same name and the same tagged data, and must be read on exactly the same + * terms. Everything else — payload, a list that is not a catalog, fields that no + * selection could have produced — must be refused. */ describe("Tier DT — structural recognition", () => { - const FAILURE = Object.freeze({ - type: "executablemd.document-target-failure", - kind: "no-match", - selector: "Missing", - matches: Object.freeze([]), - available: Object.freeze(["Alpha"]), - }); + const CATALOG = ["# T", "", "## Alpha", "", "## Beta", ""].join("\n"); + + /** Genuine failure data, so the fixtures cannot drift from the real thing. */ + const GENUINE = refusal(CATALOG, "Missing").data; + const MESSAGE = refusal(CATALOG, "Missing").message; + + function data(change: Record = {}): Record { + return { + type: "executablemd.document-target-failure", + kind: GENUINE.kind, + selector: GENUINE.selector, + matches: [...GENUINE.matches], + available: [...GENUINE.available], + ...change, + }; + } - const MESSAGE = '"Missing" matches no document target.\nAvailable targets:\n Alpha'; + function shell(payload: unknown, message = MESSAGE): Error { + const error = new Error(message); + error.name = "DocumentTargetError"; + Object.assign(error, { data: payload }); + return error; + } - /** What a separately loaded copy of this module produces: same shape, own class. */ + /** What a separately loaded copy produces: same shape, its own class. */ function foreignError(): Error { class DocumentTargetError extends Error { - readonly data = FAILURE; + readonly data = Object.freeze(data()); constructor() { super(MESSAGE); this.name = "DocumentTargetError"; @@ -759,81 +776,182 @@ describe("Tier DT — structural recognition", () => { return new DocumentTargetError(); } - it("DT52: a failure from another loaded copy is recognized", function* () { + it("DT52: a failure from another loaded copy is read on the same terms", function* () { const foreign = foreignError(); expect(foreign instanceof DocumentTargetError).toBe(false); expect(isDocumentTargetError(foreign)).toBe(true); expect(asDocumentTargetError(foreign)?.data.kind).toBe("no-match"); - // Rebuilt, not adopted: the arrays a caller reads are this module's. - expect(parseDocumentTargetFailure(FAILURE)?.available).not.toBe(FAILURE.available); }); it("DT53: this module's own failure is recognized", function* () { - expect(isDocumentTargetError(refusal(SECTIONS, "Missing"))).toBe(true); + const own = refusal(CATALOG, "Missing"); + expect(isDocumentTargetError(own)).toBe(true); + expect(asDocumentTargetError(own)?.data).toEqual(own.data); }); - it("DT54: every hostile or unreadable candidate is refused", function* () { - const withData = (data: unknown): Error => { - const error = new Error(MESSAGE); - error.name = "DocumentTargetError"; - Object.assign(error, { data }); - return error; - }; - const mutate = (change: Record) => Object.freeze({ ...FAILURE, ...change }); - - const hostile: unknown[] = [ - undefined, - null, - "a string", - new Error(MESSAGE), - // Untagged, wrongly tagged, unfrozen, and over- or under-populated data. - withData({ ...FAILURE }), - withData(mutate({ type: "other.tag" })), - withData(mutate({ kind: "made-up" })), - withData(Object.freeze({ ...FAILURE, extra: 1 })), - withData(Object.freeze({ type: FAILURE.type, kind: "no-match", selector: "Missing" })), - // A list holding something that is not a canonical reference. - withData(mutate({ available: Object.freeze([1]) })), - // `matches` populated under a kind that has none. - withData(mutate({ matches: Object.freeze(["Alpha"]) })), - // A property that refuses to answer. - withData( - Object.freeze( - Object.defineProperties( - { type: FAILURE.type, kind: "no-match", matches: [], available: [] }, - { - selector: { - get() { - throw new Error("hostile"); - }, - enumerable: true, - }, - }, - ), - ), - ), + /** + * The reconstruction claim, made where it can fail. A boundary that returned + * the candidate would pass every field assertion above and still hand a + * caller arrays somebody else can rewrite. + */ + it("DT54: recognition returns a fresh local error, never the candidate", function* () { + const foreign = foreignError(); + const safe = asDocumentTargetError(foreign); + + expect(safe).not.toBe(foreign); + expect(safe).toBeInstanceOf(DocumentTargetError); + const original = foreign as unknown as { data: { available: unknown } }; + expect(safe?.data).not.toBe(original.data); + expect(safe?.data.available).not.toBe(original.data.available); + expect(Object.isFrozen(safe?.data)).toBe(true); + expect(Object.isFrozen(safe?.data.available)).toBe(true); + }); + + it("DT55: a mutable nested list is copied, and later mutation changes nothing", function* () { + const available = ["Alpha", "Beta"]; + // Frozen outer data around a list its owner can still rewrite. + const candidate = shell(Object.freeze(data({ available }))); + const safe = asDocumentTargetError(candidate); + expect(safe?.data.available).toEqual(["Alpha", "Beta"]); + + available.push("Injected"); + available[0] = "Rewritten"; + expect(safe?.data.available).toEqual(["Alpha", "Beta"]); + expect(safe?.message).toBe(MESSAGE); + }); + + it("DT56: a revoked Proxy cannot reach through a result already built", function* () { + const revocable = Proxy.revocable(["Alpha", "Beta"], {}); + const candidate = shell(Object.freeze(data({ available: revocable.proxy }))); + const safe = asDocumentTargetError(candidate); + expect(safe?.data.available).toEqual(["Alpha", "Beta"]); + + revocable.revoke(); + // Reading the result must not touch the revoked original. + expect(safe?.data.available).toEqual(["Alpha", "Beta"]); + expect(safe?.message).toBe(MESSAGE); + expect(String(safe)).toContain("Alpha"); + // A candidate whose Proxy is already revoked is simply refused. + expect(isDocumentTargetError(candidate)).toBe(false); + expect(asDocumentTargetError(candidate)).toBe(undefined); + }); + + it("DT57: data-level extras are refused, enumerable, hidden, or symbol-keyed", function* () { + const enumerable = shell(Object.freeze(data({ extra: "payload" }))); + expect(isDocumentTargetError(enumerable)).toBe(false); + + const hidden = Object.freeze( + Object.defineProperty(data(), "extra", { value: "/etc/passwd", enumerable: false }), + ); + expect(isDocumentTargetError(shell(hidden))).toBe(false); + + const symbolic = Object.freeze( + Object.defineProperty(data(), Symbol.for("payload"), { + value: "/etc/passwd", + enumerable: true, + }), + ); + expect(isDocumentTargetError(shell(symbolic))).toBe(false); + }); + + it("DT58: a list entry that is not a canonical target is refused", function* () { + const rejected: unknown[][] = [ + ["../../etc/passwd"], + ["Alpha/../Beta"], + ["Alpha Beta"], + ["Alpha\u0009Beta"], + ["AlphaBeta"], + ["a%2fb"], + ["Alpha", "Alpha "], + [1], + [null], + // A sparse list is not a dense one. + Object.assign(Array.from({ length: 2 }) as unknown[], { 0: "Alpha" }), ]; - for (const candidate of hostile) { - expect(isDocumentTargetError(candidate)).toBe(false); - expect(asDocumentTargetError(candidate)).toBe(undefined); + for (const available of rejected) { + expect(isDocumentTargetError(shell(Object.freeze(data({ available }))))).toBe(false); } }); - it("DT55: a recognized failure carries no cause and no extra payload", function* () { - const withCause = new Error(MESSAGE); - withCause.name = "DocumentTargetError"; - Object.assign(withCause, { data: FAILURE, cause: new Error("foreign") }); + it("DT59: fields no selection could have produced are refused", function* () { + const inconsistent: Record[] = [ + // `no-match` whose selector really does match the catalog. + data({ kind: "no-match", selector: "Alpha", matches: [] }), + // `no-match` carrying matches. + data({ kind: "no-match", matches: ["Alpha"] }), + // `multiple-matches` with one match. + data({ kind: "multiple-matches", selector: "Alpha", matches: ["Alpha"] }), + // `multiple-matches` claiming a match outside the catalog. + data({ kind: "multiple-matches", selector: "**", matches: ["Alpha", "Gamma"] }), + // `invalid-selector` whose selector parses perfectly well. + data({ kind: "invalid-selector", selector: "Alpha" }), + // A kind outside the closed set. + data({ kind: "made-up" }), + // A missing member. + (() => { + const partial = data(); + delete partial["available"]; + return partial; + })(), + // A member of the wrong type. + data({ available: "Alpha" }), + data({ selector: 7 }), + ]; + for (const candidate of inconsistent) { + expect(isDocumentTargetError(shell(Object.freeze(candidate)))).toBe(false); + } + }); + + it("DT60: the Error shell is closed too", function* () { + const withCause = shell(Object.freeze(data())); + Object.assign(withCause, { cause: new Error("foreign") }); expect(isDocumentTargetError(withCause)).toBe(false); - const withPayload = new Error(MESSAGE); - withPayload.name = "DocumentTargetError"; - Object.assign(withPayload, { data: FAILURE, path: "/etc/passwd" }); + const withPayload = shell(Object.freeze(data())); + Object.assign(withPayload, { path: "/etc/passwd" }); expect(isDocumentTargetError(withPayload)).toBe(false); - // A message that does not derive from the data it claims. - const wrongMessage = new Error("something else"); - wrongMessage.name = "DocumentTargetError"; - Object.assign(wrongMessage, { data: FAILURE }); - expect(isDocumentTargetError(wrongMessage)).toBe(false); + // A diagnostic that does not derive from the data it claims. + expect(isDocumentTargetError(shell(Object.freeze(data()), "something else"))).toBe(false); + + for (const candidate of [undefined, null, "a string", new Error(MESSAGE), {}]) { + expect(isDocumentTargetError(candidate)).toBe(false); + expect(asDocumentTargetError(candidate)).toBe(undefined); + } + }); + + /** + * The payload question asked the way a consumer would ask it: after the + * boundary, is any of it still reachable by the ordinary means of passing an + * error on? + */ + it("DT61: no planted payload survives the boundary", function* () { + const planted = Object.freeze( + Object.defineProperty(data(), Symbol.for("secret"), { + value: "s3cret", + enumerable: true, + }), + ); + // Refused outright, so nothing to survive. + expect(asDocumentTargetError(shell(planted))).toBe(undefined); + + // And for a candidate that is accepted, the result carries only the + // contract: no extra own member, string or symbol, on the data or the error. + const safe = asDocumentTargetError(foreignError()); + expect(safe).toBeDefined(); + expect(Object.getOwnPropertyNames(safe?.data ?? {}).sort()).toEqual([ + "available", + "kind", + "matches", + "selector", + "type", + ]); + expect(Object.getOwnPropertySymbols(safe?.data ?? {})).toEqual([]); + expect(Object.keys(safe ?? {}).sort()).toEqual(["data", "name"]); + expect(Object.getOwnPropertySymbols(safe ?? {})).toEqual([]); + expect(JSON.stringify({ ...safe })).not.toContain("s3cret"); + expect(String(safe)).toBe(`DocumentTargetError: ${MESSAGE}`); + // A journal round trip rebuilds the same data from the same fields. + expect(parseDocumentTargetFailure(JSON.parse(JSON.stringify(safe?.data)))).toEqual(safe?.data); }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 7d02e736..e653fa00 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2848,15 +2848,35 @@ derived from the data, quotes the selector as JSON, and lists canonical encoded references, so a heading holding a control character cannot reach a diagnostic literally. -Recognition is structural and total. The data carries a stable namespaced tag, -so a failure built by a separately loaded copy of the package is recognized on -the same terms as one built locally — `instanceof` cannot answer that question -across two copies. Recognition also requires the name, the message its own data -derives, frozen data with exactly the described members, no cause, and no other -enumerable member: a recognized failure is handed onward by identity, so a -candidate carrying a path or a foreign object is refused rather than adopted. -The data is rebuilt from validated parts wherever it crosses a boundary, so -nothing a candidate owns is retained. +Recognition is structural, total, and reconstructing. The data carries a stable +namespaced tag, so a failure built by a separately loaded copy of the package is +read on the same terms as one built locally — `instanceof` cannot answer that +question across two copies. + +`asDocumentTargetError()` never returns the candidate. It validates every field +and builds a **fresh local error** from the result, so nothing the candidate +owns is handed on: a nested list stays correct after its owner mutates it, and a +list reached through a revocable Proxy stays readable after the Proxy is +revoked. This is an ordinary invocation failure with no fail-stop reason to +preserve object identity, so rebuilding costs one allocation and removes every +way payload could travel. + +A candidate is read only when all of this holds: + +- the data carries exactly `type`, `kind`, `selector`, `matches`, and + `available`, with no other own member — enumerable, non-enumerable, or + symbol-keyed; +- `matches` and `available` are dense lists whose every entry is an exact + canonical target; +- `matches` is empty for `invalid-selector` and `no-match`, and holds more than + one entry for `multiple-matches`; and +- the fields describe an outcome selection could have reached: the selector is + parsed and matched against the catalog the data supplies, and the result must + be the matches it claims. + +The Error shell is checked as strictly: the fixed name, the message its own data +derives, no cause, and no enumerable member beyond the contract. Diagnostics are +derived from the reconstructed canonical data alone. ##### A failed selection is recorded, not merely failed @@ -2879,6 +2899,34 @@ The recorded selector is sanitized invocation metadata, retained only so an ordinary failed execution can be reproduced. It never occupies the exact-target field and never reaches a workflow definition. +##### A recorded root selection is a closed protocol + +The recorded root import is parsed as a closed protocol with exactly two +supported shapes: a repository selection carrying the path, the content, and an +optional exact canonical target; and a failed selection carrying the path, the +content, and an exact failure record. An unknown kind, a missing or unreadable +member, a member of the wrong type, an extra member, a noncanonical target, and +a failure record that is not exactly this contract are each **malformed**. + +Malformed is not the same answer as "this event is not the root import". +Collapsing the two is what would let a corrupted record fall through to the +recorded terminal result, replaying an outcome the record no longer describes. + +Because the record carries the content it was taken from, the selection is +verified against it rather than merely parsed: a recorded exact target must +still resolve to itself in the recorded content, and a recorded failure must be +exactly the failure the recorded selector produces against that content. A +catalog, a match list, or a kind that the recorded document contradicts is +therefore malformed too. + +A malformed record fails before the recorded terminal result can be reused, with +one fixed, cause-free diagnostic. It never delegates, never replays the recorded +terminal error, never executes authored work, and never appends new history. + +A root import whose recorded result is not `ok` is left alone: a root can fail +for reasons that are not about selection, and those failures are not this +protocol's to interpret. + ### 5.5 The Component Api Expansion's context-dependent operations are exposed through one public @@ -7366,7 +7414,10 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | DT34–DT39 | Projection | Preamble, ancestor direct content and the selected subtree are retained; siblings are absent; a non-leaf keeps its descendants; a sole title stays | | DT40–DT43 | Positions | A retained element keeps its authored offset and line, CRLF included; frontmatter, props and return mode survive; the untargeted parse still scans the whole body | | DT44–DT47 | Inspection | The catalog is reported without selecting; a glob resolves to the exact target; an unresolvable target fails inspection; the failure's data is frozen and rebuilt | -| DT52–DT55 | Recognition | A failure from a separately loaded copy is recognized; hostile, unreadable, mutable, over-populated, cause-bearing and payload-bearing candidates are all refused | +| DT52/DT53 | Recognition | A failure from a separately loaded copy, and one built here, are read on the same terms | +| DT54–DT56 | Reconstruction | The result is a fresh local error, never the candidate; a mutable nested list is copied and later mutation changes nothing; a revoked Proxy cannot reach through a result already built | +| DT57–DT59 | Closed data | Enumerable, non-enumerable and symbol-keyed extras, entries that are not canonical targets, sparse lists, and fields no selection could have produced are all refused | +| DT60/DT61 | Closed shell | A cause, an enumerable payload, and a message that does not derive from its data are refused; no planted payload survives stringification, spreading, symbol enumeration, or a journal round trip | ### Tier TX — Targeted execution and replay @@ -7384,6 +7435,8 @@ Defined in [Workflow runs](./workflow-spec.md) §9.4 and §9.6–§9.7. | TX22/TX23 | Recorded content | An untargeted journal replays untargeted; replay projects the recorded content, not the file on disk | | TX24 | Failed selection | A journal from a selector that matched nothing never answers a later valid one | | TX25–TX27 | Failed replay | The same failing selector replays its own recorded failure with no authored effect; a different failure kind or a different selector is stale; live and replayed failures are the same structural error | +| TX28–TX32 | Malformed records | Starting from a valid failed-selection journal and corrupting only the record: a missing or non-array catalog, an unknown kind, extra record or failure data, a noncanonical or unresolvable target, and inconsistent kind/matches data are each refused before completed-Close reuse, resumed with the failing selector and with a valid one, expanding nothing and appending nothing | +| TX33 | Not vacuous | An uncorrupted record still replays its recorded failure | ### Tier SL — Own-scope context updates